Skip to main content

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:

  • transformIR runs in registration order (first registered → first called).
  • postCompile runs in registration order.
  • postExecute runs 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 bob request — plugins only see the IR; they don't have access to bob.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 →