Advanced
Less-commonly-used but load-bearing pieces of the builder: inspection, watch streams, caching, timestamp overrides, scope escape hatches, and the entry-point variants for optional modules.
Inspecting without executing
.toIR()
Returns a frozen snapshot of the current IR — what the builder would send to the resolver if a terminal were called now. Useful in tests and for debugging:
const ir = bob.flowQuery('finance/bill')
.statusMutable()
.where('data.amount', 'gt', 1000)
.toIR()
console.log(ir.statusFilters) // [{ semantic: 'mutable' }]
console.log(ir.filters) // [{ field: 'data.amount', op: 'gt', value: 1000 }]
.plan()
Runs Build → Resolve → Compile but not Execute. Returns a
FlowQueryPlan containing the raw IR, the resolved IR, the native
compiled query for the active backend, and an execution estimate.
Call .execute() on the plan to run it later:
const plan = await bob.flowQuery('finance/bill')
.status('posted')
.plan()
plan.ir // raw IR
plan.resolved // resolved IR
plan.native // { collectionName, query, queryOptions, ... } for Mongo
plan.estimate // { scanCount, indexUsed, ... } — best-effort hint
// Execute when ready
const result = await plan.execute()
Use .plan() when you want to print a query, persist it, or hand
it to a separate executor.
.clone(options?)
Deep-copy the builder so two branches can diverge without interference. Terminals consume the builder; clone when you want to call several terminals from the same filter shape:
const base = bob.flowQuery('finance/bill')
.status('posted')
.sortDesc('data.dueDate')
const [count, page] = await Promise.all([
base.clone().count(),
base.clone().limit(10).getMany('FinanceInterface.Bill'),
])
.watch(options?) — change streams
Subscribe to a real-time change stream for the filter:
const sub = bob.flowQuery('finance/bill')
.status('posted')
.watch({ debounceMs: 500 })
sub.on('change', (event) => {
// event.operationType — insert | update | delete | replace
// event.fullDocument — present on insert / update / replace
})
// Later
sub.close()
Only available for backends that support change streams (MongoDB primarily). The filter is compiled to a change-stream match stage so you only receive events for documents the filter would match.
Caching
Results can be cached by the compiler for read queries:
// Cache for 60 seconds against the default cache collection
.cache(60)
// Cache against a named cache collection
.cache(60, 'finance-cache')
// Force skip cache (e.g. after a write in the same hook)
.skipCache()
Cache keys are namespaced by document path and keyed by the compiled
query's SHA-256 hash. The cache is invalidated by writes via
registered invalidation rules on the document; ad-hoc invalidation
via .skipCache() is the escape hatch.
Caching is a read-only optimisation. Writes never hit the cache.
Scope escape hatches
Every query is implicitly scoped by the calling user's membership, company, and business unit. Most queries should not bypass these. The escape hatches exist for system-level reads and cross-tenant operations:
.skipMembership() // bypass membership scoping
.skipCompany() // bypass company scoping
.skipBusinessUnit() // bypass business unit scoping
.setCompanyId('COMP-42') // pin a specific company
.setSessionTransactionId(id) // pin a specific session transaction
.skipSession() // bypass the debe session entirely
Used deliberately these unlock cross-tenant reporting, admin tools, and migrations. Used carelessly they leak data across tenants. The legacy API called these "dangerous options" — the new API keeps them available but distinct.
Caller overrides
Rarely needed, but sometimes the call-site legitimately owns a value that the framework would otherwise set:
.docIdOverride('CUSTOM-DOC-ID') // custom docId on insert
.overrideCreatedAt(new Date('2024-01-01'))
.overrideUpdatedAt(new Date('2024-02-01'))
Use cases: migration scripts that preserve original timestamps, imports from external systems that bring their own IDs.
.skipValidation() — bypass Joi
Skip schema validation on writes. Use for migrations, system writes, or pre-validated payloads:
await bob.flowQuery('audit/log')
.skipValidation()
.insertOne(rawEvent)
See Writing for the full validation behaviour matrix.
Safe entry points for optional modules
Hooks shared across workspaces sometimes can't assume every
document exists in the active registry. flowQuerySafe and
hasDocument are the guard-railed variants:
// Returns a FlowQueryBuilder, or null if the document isn't registered.
const q = bob.flowQuerySafe('finance/bill')
if (!q) return { skipped: true, reason: 'module not available' }
const bills = await q.statusMutable().getMany()
// Boolean check — no builder created
if (bob.hasDocument('finance/bill')) { /* … */ }
// Introspection
const description = bob.describe('finance/bill')
const allPaths = bob.listDocuments()
Inspection cheat sheet
| Method | Returns | When to use |
|---|---|---|
.toIR() | Frozen FlowQueryIR | Tests, logging, fingerprinting |
.plan() | FlowQueryPlan | Inspect native query before running |
.clone() | New FlowQueryBuilder | Run several terminals from one shape |
bob.describe(path) | DocumentDescription | AI-assisted query generation |
bob.listDocuments() | string[] | Diagnostic tooling |
bob.hasDocument(path) | boolean | Guards for optional modules |
bob.flowQuerySafe(path) | FlowQueryBuilder | null | Same, returning a builder |
Next: Migrating from legacy →