Skip to main content

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​

MethodReturnsWhen to use
.toIR()Frozen FlowQueryIRTests, logging, fingerprinting
.plan()FlowQueryPlanInspect native query before running
.clone()New FlowQueryBuilderRun several terminals from one shape
bob.describe(path)DocumentDescriptionAI-assisted query generation
bob.listDocuments()string[]Diagnostic tooling
bob.hasDocument(path)booleanGuards for optional modules
bob.flowQuerySafe(path)FlowQueryBuilder | nullSame, returning a builder

Next: Migrating from legacy →