Plugins
Plugins are the extension point. A plugin can tap three hooks on the pipeline:
transformIR— before compilation, receives the resolved IR and returns a (possibly modified) IR.postCompile— after compilation, receives the native compiled query and can rewrite it.postExecute— after execution, receives the result and can reshape it.
Use them for cross-cutting concerns like generic search UIs, auditing, feature flags, and result rewriting.
The interface
interface FlowQueryPlugin<TConfig = unknown> {
readonly name: string
readonly supportedBackends: string[] // ['*'] or ['mongo', 'postgres']
transformIR?(
ir: ResolvedFlowQueryIR,
config: TConfig,
): ResolvedFlowQueryIR
postCompile?(
compiled: unknown,
ir: ResolvedFlowQueryIR,
backend: string,
config: TConfig,
): unknown
postExecute?(
result: unknown,
ir: ResolvedFlowQueryIR,
config: TConfig,
): unknown
}
Every hook is optional. Plugins implement the ones they need and skip the rest.
Registering a plugin
.use(plugin, config?) registers a plugin on a single query. The
config is passed through to each hook the plugin implements.
const result = await bob.flowQuery('finance/bill')
.use(agGridSearchPlugin, { params: gridRequest.params })
.getMany()
Registration order matters:
transformIRruns in registration order (first registered → first called).postCompileruns in registration order.postExecuteruns in reverse registration order (so the last-applied transformation is the first one undone).
Backend compatibility
Plugins declare the backends they support via
supportedBackends: string[]. Use ['*'] for backend-agnostic
plugins; otherwise list exact providers ('mongo', 'postgres',
'clickhouse'). The runner skips a plugin when the active backend
isn't supported, keeping every query safe from accidental
cross-backend logic.
Example — AG-Grid search plugin
A plugin that translates AG-Grid filter/sort models into IR filters,
applies a Mongo $facet for row totals in one round-trip, and
reshapes the result for the grid:
class AgGridSearchPlugin implements FlowQueryPlugin<AgGridConfig> {
readonly name = 'ag-grid-search'
readonly supportedBackends = ['*']
transformIR(ir, config) {
// Merge AG-Grid filterModel/sortModel into the IR
const { filters, sort } = parseAgGrid(config.params)
return { ...ir, filters: [...ir.filters, ...filters], sort: [...ir.sort, ...sort] }
}
postCompile(compiled, ir, backend, config) {
if (backend === 'mongo') {
return applyMongoFacet(compiled, config)
}
return compiled
}
postExecute(result, ir, config) {
return { rowData: result.data, lastRow: result.meta?.totalDocuments }
}
}
Used like:
const rows = await bob.flowQuery('finance/bill')
.use(agGridSearchPlugin, { params: gridRequest.params })
.getMany()
When to reach for a plugin
Good candidates:
- UI adapters — translate an external query schema into IR (AG-Grid, GraphQL where-args, search-params, etc.).
- Result shaping — add/remove envelope fields, attach presentation metadata.
- Cross-cutting writes — stamp audit fields on every mutation.
- Policy augmentation — inject additional filters based on feature flags or user context not captured by document policies.
Bad candidates:
- One-off business rules — write them in the hook instead.
- Things that need the full
bobrequest — plugins only see the IR; they don't have access tobob.flowUser,bob.cfpPath, etc.
pluginConfig on the IR
The IR carries a pluginConfig: Record<string, unknown> field. Use
it to stash plugin-specific state between hooks:
transformIR(ir, config) {
return { ...ir, pluginConfig: { ...ir.pluginConfig, [this.name]: computed } }
}
postExecute(result, ir, config) {
const stashed = ir.pluginConfig[this.name]
// …
}
Next: Advanced →