From b27ea1dc55f46dc51595256fd52a5679332e6cb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:27:30 +0000 Subject: [PATCH 1/7] fix(objectql): a data driver receives the declared driver options and nothing else The engine now hands every driver call the keys DriverOptionsSchema declares (read off the schema, minus retired tombstones) and nothing else. buildDriverOptions returns that declared set; find, findOne, insert, update, delete and the raw execute door hand it to the driver, while input.options stays the hooks' additive view of the caller's bag. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../objectql/src/engine-driver-options.ts | 102 +++++++++++++++++- .../src/engine-execute-transactions.ts | 7 +- packages/objectql/src/engine.ts | 69 ++++++++---- packages/spec/src/data/driver.zod.ts | 5 + packages/spec/src/data/hook.zod.ts | 4 +- 5 files changed, 159 insertions(+), 28 deletions(-) diff --git a/packages/objectql/src/engine-driver-options.ts b/packages/objectql/src/engine-driver-options.ts index 88621412bec..1330b39a08f 100644 --- a/packages/objectql/src/engine-driver-options.ts +++ b/packages/objectql/src/engine-driver-options.ts @@ -20,6 +20,18 @@ * * Nothing stays behind in this section: every member of it moved. * + * ## A driver is handed the declared driver options and nothing else + * + * Since the move, this module also owns the engine → driver hop's key set: + * `declaredDriverOptionKeys` (the keys `DriverOptionsSchema` declares), + * `pickDeclaredDriverOptions` (a bag cut to those keys — what + * `buildDriverOptions` now returns, and what the engine's raw `execute` hands + * its driver) and `mergeDriverOptionsIntoHookView` (the `after*` hook view of + * `input.options`, which stays the caller's bag with the driver options merged + * on). The engine builds the driver's options with `buildDriverOptions` and + * hands a driver that object; the merged view goes to hooks, never to a + * driver. + * * ## The transaction seam's same-origin half (ADR-0119, ADR-0067) * * `buildDriverOptions` threads the open transaction handle only to the driver @@ -46,7 +58,7 @@ * move. */ -import { isTenancyDisabled } from '@objectstack/spec/data'; +import { DriverOptionsSchema, isTenancyDisabled } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { // [ADR-0131 D8] The ONE tenant read predicate, shared with plugin-security's @@ -95,6 +107,85 @@ function accessibleOrganizationsOf(execCtx: ExecutionContext | undefined): strin return Array.isArray(set) && set.length > 0 ? set.map(String) : undefined; } +/** + * Does this shape member admit only absence? That is a `retiredKey()` + * tombstone (`z.never()` under its `.optional()`): declared so the removal is + * audible, never a key a driver is handed. Structural, as the spec's own + * `acceptsNothing` walker reads it, so it holds however the tombstone helper is + * spelled. + */ +function admitsOnlyAbsence(member: unknown, depth = 0): boolean { + if (depth > 6) return false; + const def = (member as { _zod?: { def?: { type?: string; innerType?: unknown } } } | undefined)?._zod?.def; + if (!def?.type) return false; + if (def.type === 'never') return true; + return def.type === 'optional' || def.type === 'nullable' || def.type === 'default' || def.type === 'readonly' + ? admitsOnlyAbsence(def.innerType, depth + 1) + : false; +} + +let declaredDriverOptionKeysMemo: ReadonlySet | undefined; + +/** + * The option keys a driver is handed: the ones `DriverOptionsSchema` declares, + * minus its tombstones. Read off the schema on first use (it is lazy) and never + * restated here, so a key the spec declares reaches every driver with no edit + * in this file, and a key it does not declare reaches none. + */ +export function declaredDriverOptionKeys(): ReadonlySet { + if (declaredDriverOptionKeysMemo === undefined) { + const shape = DriverOptionsSchema.shape as Record; + declaredDriverOptionKeysMemo = new Set(Object.keys(shape).filter((key) => !admitsOnlyAbsence(shape[key]))); + } + return declaredDriverOptionKeysMemo; +} + +/** + * The engine → driver hop: a copy of `bag` holding its declared driver-option + * keys ({@link declaredDriverOptionKeys}) and nothing else, or `undefined` when + * there is no bag. Copied as a spread copies (own enumerable keys), so a + * declared key keeps exactly the value it had. + * + * A caller's option bag is not a driver option bag: it carries the query, the + * write-observability callbacks and the other engine-level keys of its + * `Engine*Options` contract, which the engine reads and the driver contract + * never declared. They are dropped here without a sound — the caller's bag was + * already judged at the engine's own door, so nothing dropped here is the + * caller's mistake. + */ +export function pickDeclaredDriverOptions(bag: unknown): Record | undefined { + if (bag === null || typeof bag !== 'object') return undefined; + const source = bag as Record; + const picked: Record = {}; + for (const key of declaredDriverOptionKeys()) { + if (Object.prototype.propertyIsEnumerable.call(source, key)) picked[key] = source[key]; + } + return picked; +} + +/** + * `input.options` once the driver options are built — the `after*` phase of + * the HookContext PHASE contract (`hook.zod.ts`): the caller's bag with the + * driver options merged on. ADDITIVE, as that contract states: every key the + * caller's bag carried stays readable to a hook. This view is the hooks', and + * only the hooks': the driver is handed `driverOptions` itself. + * + * Answers the caller's own bag, the same object, when the driver options add + * nothing to it, and `driverOptions` when there is no caller bag. + */ +export function mergeDriverOptionsIntoHookView( + callerBag: unknown, + driverOptions: Record | undefined, +): unknown { + if (driverOptions === undefined) return callerBag; + if (callerBag === null || typeof callerBag !== 'object') return driverOptions; + const bag = callerBag as Record; + const addsNothing = Object.keys(driverOptions).every( + (key) => Object.prototype.propertyIsEnumerable.call(bag, key) && bag[key] === driverOptions[key], + ); + return addsNothing ? callerBag : { ...bag, ...driverOptions }; +} + /** The body of `ObjectQL#buildDriverOptions`; the method's docblock there is its contract. */ export function buildDriverOptions( this: Pick, @@ -161,8 +252,13 @@ export function buildDriverOptions( tenantObject && isTenantAuditInScope && !scope.writeTarget && this.wallExemptedOperation(execCtx, scope.recordedVerdict); const preserveAudit = (execCtx as any)?.preserveAudit === true; - if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit && !wallExempt) return base; - const opts: any = base && typeof base === 'object' ? { ...base } : {}; + // A driver is handed the keys `DriverOptionsSchema` declares and nothing + // else: the declared keys of `base` (a caller's explicit `tenantId` / + // `bypassTenantAudit` / `transaction` still win below), never the rest of + // the bag it rode in on. See {@link pickDeclaredDriverOptions}. + const declared = pickDeclaredDriverOptions(base); + if (!hasTx && !hasTenant && !isSystem && !hasTz && !preserveAudit && !wallExempt) return declared; + const opts: any = declared ?? {}; if (hasTx && opts.transaction === undefined) { opts.transaction = tx; } diff --git a/packages/objectql/src/engine-execute-transactions.ts b/packages/objectql/src/engine-execute-transactions.ts index 6a5123742df..49c5e9cc2a2 100644 --- a/packages/objectql/src/engine-execute-transactions.ts +++ b/packages/objectql/src/engine-execute-transactions.ts @@ -46,6 +46,7 @@ import type { IDataDriver } from '@objectstack/core'; import { redactPropagatedDriverFault } from '@objectstack/types'; import { CrossDatasourceTransactionWriteError } from './transaction-errors.js'; +import { pickDeclaredDriverOptions } from './engine-driver-options.js'; import type { ObjectQL } from './engine.js'; /** @@ -123,8 +124,12 @@ export async function execute( // common-table-expression form, a dialect's upsert or merge verb), so // the cut must not wait for the predicate to recognise it. The door // knows it sent a statement, and says so. + // + // The driver is handed the declared driver options of the bag and nothing + // else: `object` / `datasource` chose the driver and `args` / `params` are + // the statement's, already taken above. try { - return await driver.execute(rawCommand, params, options); + return await driver.execute(rawCommand, params, pickDeclaredDriverOptions(options)); } catch (e) { throw redactPropagatedDriverFault(e, { statementSent: true }); } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 7de2a4ccda1..a08721baf39 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -116,7 +116,7 @@ import { serveReferenceNames } from './engine-reference-name-visible.js'; // [#25522 cut 12] The driver options every driver call receives, and the tenancy // posture and Layer 0 verdict they are scoped by; the methods of the same names // below hand each body the members it reads (`DriverOptionsHost`). -import { buildDriverOptions, driverReadScope, driverWriteTarget, engineLayer0Verdict, resolveEnginePosture, wallExemptedOperation } from './engine-driver-options.js'; +import { buildDriverOptions, driverReadScope, driverWriteTarget, engineLayer0Verdict, mergeDriverOptionsIntoHookView, resolveEnginePosture, wallExemptedOperation } from './engine-driver-options.js'; // [#25522 cut 13] The `sys_migration` flag reads, the certification question a // driver is handed, and the admitted-violation tally and boot advisory; the // methods of the same names below hand each body the members it reads (`MigrationFlagsHost`). @@ -2454,11 +2454,20 @@ export class ObjectQL implements IObjectQLEngine { * the remote and to the layers above (RBAC/RLS, the datasource binding), * not to a predicate the platform guesses onto someone else's table. * - * Existing user-supplied shapes (transactions, AST extras) are preserved by - * spreading them first — an explicitly-passed `base.tenantId` is deliberate - * caller intent and still wins, under every verdict. System / isSystem - * callers may still cross tenants by clearing `tenantId` themselves on the - * resulting object; this helper does not mask the system path. + * # Declared keys only + * + * The result holds the keys `DriverOptionsSchema` declares and nothing else + * (`pickDeclaredDriverOptions`, `engine-driver-options.ts`). `base` is + * usually a caller's whole option bag — the query, the write-observability + * callbacks and the other engine-level keys ride in it — and only its + * declared keys are kept: an explicitly-passed `base.tenantId` / + * `base.transaction` / `base.bypassTenantAudit` is deliberate caller intent + * and still wins, under every verdict; everything else in the bag is the + * engine's, never a driver's. That is why a verb hands its driver THIS + * object and keeps `input.options` as the hooks' additive view + * (`mergeDriverOptionsIntoHookView`). System / isSystem callers may still + * cross tenants by clearing `tenantId` themselves on the resulting object; + * this helper does not mask the system path. */ private buildDriverOptions( object: string, @@ -6637,14 +6646,17 @@ export class ObjectQL implements IObjectQLEngine { ql: this }; await this.triggerHooks('beforeFind', hookContext); - hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + // The driver is handed `driverOptions` — the declared driver options and + // nothing else; `input.options` stays the hooks' additive view of the bag. + const driverOptions = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); // [ADR-0139 D2] The executor boundary: the AST is final here — after the // doors, the middleware chain and `beforeFind` — so every `ReadableIds` // leaf is composed (or refused) on it, immediately before the driver. const driverAst = await this.composeReadableIdsAtBoundary(object, 'find', hookContext.input.ast as QueryAST, opCtx.context); try { - let result = await driver.find(object, driverAst, hookContext.input.options as any); + let result = await driver.find(object, driverAst, driverOptions); // [#21571] The read's default projection is the DECLARED field set: // a column no metadata declares (a field retired in an upgrade, @@ -6865,11 +6877,13 @@ export class ObjectQL implements IObjectQLEngine { ql: this }; await this.triggerHooks('beforeFind', hookContext); - hookContext.input.options = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + // The driver is handed `driverOptions` alone, as in `find`. + const driverOptions = this.buildDriverOptions(objectName, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); // [ADR-0139 D2] The executor boundary, as in `find`. const driverAst = await this.composeReadableIdsAtBoundary(objectName, 'findOne', hookContext.input.ast as QueryAST, opCtx.context); - let result = await driver.findOne(objectName, driverAst, hookContext.input.options as any); + let result = await driver.findOne(objectName, driverAst, driverOptions); // [#21571] Same default projection as `find`, same position: the // declared field set, applied to the row as the driver returned it. @@ -8030,8 +8044,11 @@ export class ObjectQL implements IObjectQLEngine { // not a read scope (ADR-0131 D8 leaves the write target alone). { writeTarget: true }, ); + // Every driver call below is handed `driverOptions` — the declared driver + // options and nothing else; the hooks keep their additive view of the bag. + const hookOptions = mergeDriverOptionsIntoHookView(optionsBase, driverOptions); for (const rowCtx of rowHookContexts) { - rowCtx.input.options = driverOptions; + rowCtx.input.options = hookOptions; } // [#27495] The credential rows each row's secret fields are sealed into, // and the ones this write has actually written to `sys_secret`. @@ -9640,7 +9657,10 @@ export class ObjectQL implements IObjectQLEngine { // refuses before any driver call. reportDroppedFields(preComputedPayload, computedStripUpdate.rows[0] as Record | null | undefined, 'computed'); - hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + // Every driver call below is handed `driverOptions` — the declared driver + // options and nothing else; `input.options` is the hooks' additive view. + const driverOptions = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); // [#27495] The credential rows this write seals, the ones it has written // to `sys_secret`, and the handles its committed statement moved the // secret columns off. @@ -9760,7 +9780,7 @@ export class ObjectQL implements IObjectQLEngine { // secret channel (which carries the secret-arm refusal). this.refuseEmptyPasswordFields(object, hookContext.input.data as Record); // [#27582] This record's own stored row is the pre-image read above. - sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options, async () => [priorRecord]); + sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, driverOptions, async () => [priorRecord]); // [#21663] Scope `'skip'` — the public `validateRecord` IS that // scope: the readonly strip has NOT run yet, so a readonly value // here may be a caller's the strip is about to drop — judged, a @@ -10104,8 +10124,8 @@ export class ObjectQL implements IObjectQLEngine { // `OperationContext.writePrecondition`. const precondition = opCtx.writePrecondition; result = precondition && Object.keys(precondition).length > 0 - ? await this.updateByIdExpecting(driver, object, hookContext.input.id, hookContext.input.data as Record, precondition, hookContext.input.options as Parameters[2]) - : await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, hookContext.input.options as any); + ? await this.updateByIdExpecting(driver, object, hookContext.input.id, hookContext.input.data as Record, precondition, driverOptions) + : await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record, driverOptions); } catch (driverError) { throw envelopeDriverWriteRefusal(driverError, object, this.getSchema(object), 'update', updateMsgCtx); } @@ -10187,7 +10207,7 @@ export class ObjectQL implements IObjectQLEngine { this.refuseEmptyPasswordFields(object, hookContext.input.data as Record); // [#27582] The rows this write lands on are the matched rows: the // D7 memo, read here only when a non-system payload carries a ref. - sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, hookContext.input.options, readPriorRows); + sealedSecrets = await this.encryptSecretFields(object, hookContext.input.data as Record, opCtx.context, driverOptions, readPriorRows); // [#21663] Scope `'skip'` — the public `validateRecord` IS that // scope: the readonly strip has NOT run yet, so a readonly value // here may be a caller's the strip is about to drop — judged, a @@ -10538,7 +10558,7 @@ export class ObjectQL implements IObjectQLEngine { if (summaryKeyColumns.length > 0) { const previous = priorRowsRead ? ((await readPriorRows()) ?? []) - : (((await driver.find(object, { ...ast, fields: summaryKeyColumns }, hookContext.input.options as Parameters[2])) ?? []) as Record[]); + : (((await driver.find(object, { ...ast, fields: summaryKeyColumns }, driverOptions)) ?? []) as Record[]); const payload = hookContext.input.data as Record; summaryRows = { previous, next: previous.map((row) => ({ ...row, ...payload })) }; } @@ -10552,10 +10572,10 @@ export class ObjectQL implements IObjectQLEngine { ? [] : priorRowsRead ? ((await readPriorRows()) ?? []) - : (((await driver.find(object, { ...ast, fields: ['id', ...movedSecretColumns] }, hookContext.input.options as Parameters[2])) ?? []) as Record[]); + : (((await driver.find(object, { ...ast, fields: ['id', ...movedSecretColumns] }, driverOptions)) ?? []) as Record[]); await this.persistSecretRows(sealedSecrets, writtenSecrets); try { - result = await driver.updateMany!(object, ast, hookContext.input.data as Record, hookContext.input.options as any); + result = await driver.updateMany!(object, ast, hookContext.input.data as Record, driverOptions); } catch (driverError) { throw envelopeDriverWriteRefusal(driverError, object, this.getSchema(object), 'update', updateMsgCtx); } @@ -11714,7 +11734,10 @@ export class ObjectQL implements IObjectQLEngine { } } - hookContext.input.options = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + // Every driver call below is handed `driverOptions` — the declared driver + // options and nothing else; `input.options` is the hooks' additive view. + const driverOptions = this.buildDriverOptions(object, opCtx.context, hookContext.input.options as any, { recordedVerdict: opCtx.tenantLayer0Verdict }); + hookContext.input.options = mergeDriverOptionsIntoHookView(hookContext.input.options, driverOptions); try { let result; @@ -11818,7 +11841,7 @@ export class ObjectQL implements IObjectQLEngine { if (plan === 'none') { if (summaryKeyColumns.length > 0) { summaryPriorRows = predicateMatchedRows - ?? (((await driver.find(object, { ...opCtx.ast!, fields: summaryKeyColumns }, hookContext.input.options as Parameters[2])) ?? []) as Record[]); + ?? (((await driver.find(object, { ...opCtx.ast!, fields: summaryKeyColumns }, driverOptions)) ?? []) as Record[]); } // [#27581] The handles the matched rows hold, read BEFORE the // statement removes them: the per-row hook read when it was @@ -11826,9 +11849,9 @@ export class ObjectQL implements IObjectQLEngine { // this write's AST and options. No secret column, no read. if (secretColumns.length > 0) { deletedSecrets = deletedSecretSlots(deleteSchema, predicateMatchedRows - ?? (((await driver.find(object, { ...opCtx.ast!, fields: ['id', ...secretColumns] }, hookContext.input.options as Parameters[2])) ?? []) as Record[])); + ?? (((await driver.find(object, { ...opCtx.ast!, fields: ['id', ...secretColumns] }, driverOptions)) ?? []) as Record[])); } - result = await driver.deleteMany!(object, opCtx.ast!, hookContext.input.options as any); + result = await driver.deleteMany!(object, opCtx.ast!, driverOptions); } else { // Otherwise: resolve the matched rows through the composed, // row-scoped AST, run `cascadeDeleteRelations` once over them diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index b1f9d4a2e03..9dc43a28384 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -8,6 +8,11 @@ import { retiredKey } from '../shared/retired-key'; /** * Common Driver Options * Passed to most driver methods to control behavior (transactions, timeouts, etc.) + * + * A closed contract at the engine → driver hop: the ObjectQL engine hands a + * driver the keys declared here and nothing else (retired tombstones aside), + * whatever else rode in the caller's engine option bag. A key a driver needs is + * declared here first. */ import { lazySchema } from '../shared/lazy-schema'; export const DriverOptionsSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index c8281f0e192..8052ab83d9f 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -883,9 +883,11 @@ export const HookContextSchema = lazySchema(() => z.object({ * handlers return — before the driver call — merges the driver-facing keys * onto it (`buildDriverOptions`: transaction, tenantId, timezone, …). So a * `before*` handler reads the CALLER's bag, `where` and `multi` included; an - * `after*` handler and the driver read the `DriverOptions` view. The merge is + * `after*` handler reads the merged view. The merge is * ADDITIVE — it spreads the caller's bag and adds keys, never strips one — so * the widening is one-way and nothing a `before*` handler saw disappears. + * The merged view is the hooks', not the driver's: a driver is handed the + * keys `DriverOptionsSchema` declares and nothing else. * #5997 corrected the two `before` envelope rows above, which had said `DriverOptions` * (a type that declares no `where` and no `multi`): that is not what the * engine builds there, and not what the two consumers named below read. From 7319d7c7f108961807b24a8b3cde76947333eaf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:33:02 +0000 Subject: [PATCH 2/7] test(objectql): pin that a driver receives only declared driver option keys A recording driver double reads the options argument as the driver receives it on find, findOne, insert, update, delete (by id and multi), count and the raw execute door: every key is one DriverOptionsSchema declares. Controls pin that transaction, tenantId, timezone, preserveAudit and an explicit bypassTenantAudit still arrive, and that the after* hook view of input.options stays additive. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../src/engine-driver-options.test.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 packages/objectql/src/engine-driver-options.test.ts diff --git a/packages/objectql/src/engine-driver-options.test.ts b/packages/objectql/src/engine-driver-options.test.ts new file mode 100644 index 00000000000..c07f0bffde7 --- /dev/null +++ b/packages/objectql/src/engine-driver-options.test.ts @@ -0,0 +1,245 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The engine → driver hop is a closed contract: a data driver is handed the + * keys `DriverOptionsSchema` declares and nothing else (the precedent is the + * closed engine option bags of #4400, one hop further down). + * + * The seam under test is the options argument as a driver RECEIVES it, read + * off a recording driver double — the shape `engine-driver-read-scope.test.ts` + * uses, since `@objectstack/objectql` cannot import a driver. Each verb is + * driven with a caller bag that carries its own engine-level keys (`where`, + * `fields`, `limit`, `multi`, a write-observability callback), because a + * caller's bag is what the engine builds a driver's options from. + * + * Both directions are pinned: nothing undeclared arrives (the rule), and every + * declared key the engine threads still arrives with its value (the control), + * so the rule cannot be met by handing the driver nothing. A third block pins + * that the `after*` hook view of `input.options` stays ADDITIVE (HookContext + * PHASE contract) — the cut is the driver's, never the hooks'. + */ + +import { describe, it, expect } from 'vitest'; +import { DriverOptionsSchema } from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { ObjectQL } from './engine.js'; +import { + declaredDriverOptionKeys, + mergeDriverOptionsIntoHookView, + pickDeclaredDriverOptions, +} from './engine-driver-options.js'; + +const ORG = 'org_a'; +const TZ = 'Asia/Shanghai'; +const TRX = { handle: 'trx_1' }; + +/** The caller every verb runs as: an organization, a timezone and an open unit. */ +const CALLER: ExecutionContext = { + userId: 'usr_1', + tenantId: ORG, + timezone: TZ, + transaction: TRX, +} as ExecutionContext; + +interface ReceivedCall { + method: string; + /** Every own key of the options object exactly as the driver received it. */ + keys: Array; + options: Record | undefined; +} + +/** + * A driver double that records the options argument of every data method it + * is handed, and otherwise serves one in-memory table. + */ +function makeRecordingDriver(received: ReceivedCall[]) { + const rows = new Map>(); + let nextId = 0; + const record = (method: string, options: unknown) => { + const opts = options && typeof options === 'object' ? (options as Record) : undefined; + received.push({ method, keys: opts ? Reflect.ownKeys(opts) : [], options: opts ? { ...opts } : undefined }); + }; + const matching = (where: any) => + [...rows.values()].filter((r) => !where || Object.entries(where).every(([k, v]) => k.startsWith('$') || r[k] === v)); + const driver: any = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async syncSchema() {}, + async execute(_command: unknown, _params: unknown, options: unknown) { record('execute', options); return []; }, + async find(_o: string, ast: any, options: unknown) { record('find', options); return matching(ast?.where); }, + async findOne(_o: string, ast: any, options: unknown) { record('findOne', options); return matching(ast?.where)[0] ?? null; }, + async count(_o: string, ast: any, options: unknown) { record('count', options); return matching(ast?.where).length; }, + async create(_o: string, data: any, options: unknown) { + record('create', options); + nextId += 1; + const row = { ...data, id: data.id ?? `r_${nextId}` }; + rows.set(row.id, row); + return row; + }, + async bulkCreate(_o: string, data: any[], options: unknown) { + record('bulkCreate', options); + return data.map((d) => { nextId += 1; const row = { ...d, id: d.id ?? `r_${nextId}` }; rows.set(row.id, row); return row; }); + }, + async update(_o: string, id: string, data: any, options: unknown) { + record('update', options); + const cur = rows.get(id); + if (!cur) return null; + const next = { ...cur, ...data, id }; + rows.set(id, next); + return next; + }, + async updateMany(_o: string, ast: any, data: any, options: unknown) { + record('updateMany', options); + const hit = matching(ast?.where); + for (const r of hit) rows.set(r.id as string, { ...r, ...data, id: r.id }); + return hit.length; + }, + async delete(_o: string, id: string, options: unknown) { record('delete', options); return rows.delete(id); }, + async deleteMany(_o: string, ast: any, options: unknown) { + record('deleteMany', options); + const hit = matching(ast?.where); + for (const r of hit) rows.delete(r.id as string); + return hit.length; + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + }; + return driver; +} + +/** Tenant-scoped by default: the registry injects `organization_id`. */ +const TASK = { name: 'dopt_task', fields: { title: { type: 'text' }, status: { type: 'text' } } } as any; + +async function makeEngine() { + const received: ReceivedCall[] = []; + const engine = new ObjectQL(); + engine.registerDriver(makeRecordingDriver(received), true); + await engine.init(); + engine.registry.registerObject(TASK, 'com.example.driver-options'); + engine.setTenancyPostureProvider(() => 'isolated'); + return { engine, received }; +} + +/** Drive every verb the rule names, each with its caller bag's own keys. */ +async function driveEveryVerb(engine: ObjectQL): Promise { + const [a] = (await engine.insert('dopt_task', [{ title: 'a', status: 'todo' }, { title: 'b', status: 'todo' }], { + context: CALLER, + onFieldsDropped: () => {}, + } as any)) as Array>; + await engine.find('dopt_task', { where: { status: 'todo' }, fields: ['title'], limit: 5, offset: 0, context: CALLER }); + await engine.findOne('dopt_task', { where: { id: a!.id }, context: CALLER }); + await engine.count('dopt_task', { where: { status: 'todo' } }, { context: CALLER }); + await engine.update('dopt_task', { id: a!.id, title: 'a2' }, { context: CALLER, onFieldsDropped: () => {} } as any); + await engine.update('dopt_task', { status: 'done' }, { where: { status: 'todo' }, multi: true, context: CALLER } as any); + await engine.delete('dopt_task', { where: { id: a!.id }, context: CALLER } as any); + await engine.delete('dopt_task', { where: { status: 'done' }, multi: true, context: CALLER } as any); +} + +describe('a data driver receives the declared driver options and nothing else', () => { + it('the hop key set is DriverOptionsSchema\'s declared keys; the only shape keys it leaves out admit no value', () => { + const shape = DriverOptionsSchema.shape as Record; + const declared = declaredDriverOptionKeys(); + for (const key of declared) expect(Object.keys(shape)).toContain(key); + for (const key of ['transaction', 'tenantId', 'tenantIds', 'timezone', 'preserveAudit', 'bypassTenantAudit']) { + expect(declared.has(key), key).toBe(true); + } + // What is left out is a retired tombstone: it refuses every value. + const left = Object.keys(shape).filter((k) => !declared.has(k)); + expect(left.length).toBeGreaterThan(0); + for (const key of left) { + for (const probe of [1, 'x', true, {}, [], null]) { + expect(shape[key]!.safeParse(probe).success, `${key} accepted ${JSON.stringify(probe)}`).toBe(false); + } + } + }); + + it('find, findOne, insert, update and delete hand the driver only declared option keys', async () => { + const { engine, received } = await makeEngine(); + await driveEveryVerb(engine); + + const methods = new Set(received.map((c) => c.method)); + // Not vacuous: every verb the rule names really reached the driver. + for (const m of ['find', 'findOne', 'create', 'update', 'delete', 'updateMany', 'deleteMany', 'count']) { + expect(methods.has(m) || (m === 'create' && methods.has('bulkCreate')), `driver.${m} was called`).toBe(true); + } + const declared = declaredDriverOptionKeys(); + for (const call of received) { + for (const key of call.keys) { + expect(typeof key === 'string' && declared.has(key), `driver.${call.method} received undeclared key ${String(key)}`).toBe(true); + } + } + }); + + it('the raw execute door hands the driver only declared option keys', async () => { + const { engine, received } = await makeEngine(); + await engine.execute('SELECT 1', { object: 'dopt_task', args: [], transaction: TRX }); + const call = received.find((c) => c.method === 'execute'); + expect(call).toBeDefined(); + expect(call!.keys).toEqual(['transaction']); + expect(call!.options!.transaction).toBe(TRX); + }); +}); + +describe('control: the declared keys the engine threads still reach the driver', () => { + it('transaction, tenantId and timezone arrive on find, findOne, insert, update and delete', async () => { + const { engine, received } = await makeEngine(); + await driveEveryVerb(engine); + + for (const method of ['find', 'findOne', 'update', 'delete']) { + const call = received.find((c) => c.method === method); + expect(call, `driver.${method} was called`).toBeDefined(); + expect(call!.options!.transaction, method).toBe(TRX); + expect(call!.options!.tenantId, method).toBe(ORG); + expect(call!.options!.timezone, method).toBe(TZ); + } + const insert = received.find((c) => c.method === 'bulkCreate' || c.method === 'create'); + expect(insert!.options!.transaction).toBe(TRX); + expect(insert!.options!.tenantId).toBe(ORG); + expect(insert!.options!.timezone).toBe(TZ); + }); + + it('preserveAudit and a caller\'s explicit bypassTenantAudit arrive unchanged', async () => { + const { engine, received } = await makeEngine(); + const [row] = (await engine.insert('dopt_task', [{ title: 'a' }], { context: CALLER } as any)) as Array>; + await engine.update( + 'dopt_task', + { id: row!.id, title: 'b' }, + { context: { ...CALLER, preserveAudit: true }, bypassTenantAudit: true } as any, + ); + const update = received.filter((c) => c.method === 'update').at(-1)!; + expect(update.options!.preserveAudit).toBe(true); + expect(update.options!.bypassTenantAudit).toBe(true); + }); +}); + +describe('the after* hook view of input.options stays additive', () => { + it('an afterFind hook still reads the caller\'s own keys beside the driver options', async () => { + const { engine } = await makeEngine(); + const seen: Array> = []; + engine.registerHook('afterFind', async (ctx: any) => { seen.push(ctx.input.options); }, { object: 'dopt_task' }); + await engine.find('dopt_task', { where: { status: 'todo' }, limit: 3, context: CALLER }); + expect(seen).toHaveLength(1); + expect(seen[0]!.limit).toBe(3); + expect(seen[0]!.where).toEqual({ status: 'todo' }); + expect(seen[0]!.tenantId).toBe(ORG); + }); + + it('an afterUpdate hook still reads a write-observability callback the caller passed', async () => { + const { engine } = await makeEngine(); + const [row] = (await engine.insert('dopt_task', [{ title: 'a' }], { context: CALLER } as any)) as Array>; + const callback = () => {}; + let seenCallback: unknown; + engine.registerHook('afterUpdate', async (ctx: any) => { seenCallback = ctx.input.options?.onMentionsWithheld; }, { object: 'dopt_task' }); + await engine.update('dopt_task', { id: row!.id, title: 'b' }, { context: CALLER, onMentionsWithheld: callback } as any); + expect(seenCallback).toBe(callback); + }); + + it('merging adds the driver options and answers the caller\'s own bag when nothing is added', () => { + const bag = { where: { a: 1 }, tenantId: ORG }; + expect(mergeDriverOptionsIntoHookView(bag, pickDeclaredDriverOptions(bag))).toBe(bag); + expect(mergeDriverOptionsIntoHookView(bag, { tenantId: ORG, timezone: TZ })).toEqual({ where: { a: 1 }, tenantId: ORG, timezone: TZ }); + expect(mergeDriverOptionsIntoHookView(undefined, { tenantId: ORG })).toEqual({ tenantId: ORG }); + expect(mergeDriverOptionsIntoHookView(bag, undefined)).toBe(bag); + }); +}); From cbb21bf8211f0947aef74f8a96e353c2141aa7c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:36:55 +0000 Subject: [PATCH 3/7] chore(changeset): objectql patch, a driver receives the declared driver options only Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/driver-options-declared-keys-only.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/driver-options-declared-keys-only.md diff --git a/.changeset/driver-options-declared-keys-only.md b/.changeset/driver-options-declared-keys-only.md new file mode 100644 index 00000000000..9c5495128b7 --- /dev/null +++ b/.changeset/driver-options-declared-keys-only.md @@ -0,0 +1,16 @@ +--- +'@objectstack/objectql': patch +--- + +fix(objectql): a data driver receives the declared driver options and nothing else + +Clause-②: no +Metadata-protocol: none + +The engine → driver hop is a closed contract, as the engine's own option bags are: every driver call the engine makes is handed the keys `DriverOptionsSchema` declares and no other key. + +- **Which calls.** `find`, `findOne`, `count`, `aggregate`, `insert`, `update` and `delete` (by id and multi-row), the reads and writes those verbs make along the way, and the raw `execute` door. +- **What arrives, unchanged.** `transaction`, `tenantId`, `tenantIds`, `timezone`, `preserveAudit` and `bypassTenantAudit` reach the driver exactly as before, and a caller's explicit value still wins. `timeoutMs`, `skipCache` and `traceContext` pass when a caller sets them. The retired `timeout` tombstone is not handed on. +- **What stays with the engine.** The rest of a caller's engine option bag (the query keys, the write-observability callbacks and the other `Engine*Options` entries) is read by the engine and is no longer copied onto a driver's options. A driver reads the query from its `query` argument, as the driver contract declares. +- **Hooks are unchanged.** An `after*` hook still reads `input.options` as the caller's bag with the driver options merged on. +- **Who moves.** A driver that read a key `DriverOptionsSchema` does not declare off its options argument: read the query from the `query` argument, or have the key declared on `DriverOptionsSchema` first. No driver in this repository reads one. From df18a786db5d49b6a41858c27691dca796d085b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:14:14 +0000 Subject: [PATCH 4/7] test(objectql): the recording driver's find applies the query's limit check:objectql-double-limit grades every in-memory find double: apply the caller's bound by presence after the filter, or refuse it. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- packages/objectql/src/engine-driver-options.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/objectql/src/engine-driver-options.test.ts b/packages/objectql/src/engine-driver-options.test.ts index c07f0bffde7..2811ed98e21 100644 --- a/packages/objectql/src/engine-driver-options.test.ts +++ b/packages/objectql/src/engine-driver-options.test.ts @@ -68,7 +68,11 @@ function makeRecordingDriver(received: ReceivedCall[]) { async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async syncSchema() {}, async execute(_command: unknown, _params: unknown, options: unknown) { record('execute', options); return []; }, - async find(_o: string, ast: any, options: unknown) { record('find', options); return matching(ast?.where); }, + async find(_o: string, ast: any, options: unknown) { + record('find', options); + const hit = matching(ast?.where); + return typeof ast?.limit === 'number' ? hit.slice(0, ast.limit) : hit; + }, async findOne(_o: string, ast: any, options: unknown) { record('findOne', options); return matching(ast?.where)[0] ?? null; }, async count(_o: string, ast: any, options: unknown) { record('count', options); return matching(ast?.where).length; }, async create(_o: string, data: any, options: unknown) { From de02e502876acd7ecb7636fdfb0551ace7df8e3b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 15:25:35 +0000 Subject: [PATCH 5/7] test(objectql): expansion sub-read pins read the marker where the middleware reads it Four engine.test.ts cases asserted the expansion marker on the driver's options argument. The driver is now handed declared driver options only, so those cases assert that, and the file-reference case observes the marker at the middleware seam, as the existing lookup-expansion routing case does. Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- packages/objectql/src/engine.test.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 04666dd12ed..b2a74464a72 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -1602,6 +1602,12 @@ describe('ObjectQL Engine', () => { { id: 'file_b', name: 'b.png', size: 20, mime_type: 'image/png', status: 'committed' }, ]); + const seen: Array<{ object: string; expandRead: boolean }> = []; + engine.registerMiddleware(async (opCtx: any, next: () => Promise) => { + seen.push({ object: opCtx.object, expandRead: opCtx.context?.__expandRead === true }); + await next(); + }); + const result = await engine.find('doc', {}); expect(result[0].cover).toEqual({ id: 'file_a', name: 'a.png', size: 10, mimeType: 'image/png', url: '/api/v1/storage/files/file_a' }); @@ -1612,8 +1618,12 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenLastCalledWith( 'sys_file', expect.objectContaining({ where: { id: { $in: ['file_a', 'file_b'] } } }), - expect.objectContaining({ context: { __expandRead: true } }), + expect.not.objectContaining({ context: expect.anything() }), ); + // The batched sys_file read is an expansion sub-read: the middleware + // sees its `__expandRead` marker; the driver is handed declared + // driver options only. + expect(seen).toContainEqual({ object: 'sys_file', expandRead: true }); }); it('leaves inline-blob values and non-matching strings untouched (dual-mode)', async () => { @@ -1728,9 +1738,10 @@ describe('ObjectQL Engine', () => { expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice', created_at: null, updated_at: null }); expect(result[1].assignee).toEqual({ id: 'u2', name: 'Bob', created_at: null, updated_at: null }); - // Verify the expand query used $in. The expand sub-read now routes - // through the secured `find` path ([#2850]), so the referenced - // read carries the `__expandRead` marker in its context. + // Verify the expand query used $in. The expand sub-read routes + // through the secured `find` path ([#2850]); its `__expandRead` + // marker is read by the middleware (pinned by the [#2850] case + // below), and the driver is handed declared driver options only. expect(mockDriver.find).toHaveBeenCalledTimes(2); expect(mockDriver.find).toHaveBeenLastCalledWith( 'user', @@ -1738,7 +1749,7 @@ describe('ObjectQL Engine', () => { object: 'user', where: { id: { $in: ['u1', 'u2'] } }, }), - expect.objectContaining({ context: { __expandRead: true } }), + expect.not.objectContaining({ context: expect.anything() }), ); }); @@ -1775,7 +1786,7 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenLastCalledWith( 'sys_user', expect.objectContaining({ where: { id: { $in: ['u1'] } } }), - expect.objectContaining({ context: { __expandRead: true } }), + expect.not.objectContaining({ context: expect.anything() }), ); }); @@ -2365,7 +2376,7 @@ describe('ObjectQL Engine', () => { expect.objectContaining({ where: { id: { $in: ['u1', 'u2'] } }, }), - expect.objectContaining({ context: { __expandRead: true } }), + expect.not.objectContaining({ context: expect.anything() }), ); expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' }); expect(result[1].assignee).toEqual({ id: 'u1', name: 'Alice' }); From 10142d759859892a248487e0ba3524fcf5aef59f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 16:30:40 +0000 Subject: [PATCH 6/7] chore(changeset): name the threaded keys only, the rest as the schema's other declared keys Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/driver-options-declared-keys-only.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/driver-options-declared-keys-only.md b/.changeset/driver-options-declared-keys-only.md index 9c5495128b7..0ea42b31d12 100644 --- a/.changeset/driver-options-declared-keys-only.md +++ b/.changeset/driver-options-declared-keys-only.md @@ -10,7 +10,7 @@ Metadata-protocol: none The engine → driver hop is a closed contract, as the engine's own option bags are: every driver call the engine makes is handed the keys `DriverOptionsSchema` declares and no other key. - **Which calls.** `find`, `findOne`, `count`, `aggregate`, `insert`, `update` and `delete` (by id and multi-row), the reads and writes those verbs make along the way, and the raw `execute` door. -- **What arrives, unchanged.** `transaction`, `tenantId`, `tenantIds`, `timezone`, `preserveAudit` and `bypassTenantAudit` reach the driver exactly as before, and a caller's explicit value still wins. `timeoutMs`, `skipCache` and `traceContext` pass when a caller sets them. The retired `timeout` tombstone is not handed on. +- **What arrives, unchanged.** `transaction`, `tenantId`, `tenantIds`, `timezone`, `preserveAudit` and `bypassTenantAudit` reach the driver exactly as before, and a caller's explicit value still wins. The schema's other declared keys pass when a caller sets them. The retired `timeout` tombstone is not handed on. - **What stays with the engine.** The rest of a caller's engine option bag (the query keys, the write-observability callbacks and the other `Engine*Options` entries) is read by the engine and is no longer copied onto a driver's options. A driver reads the query from its `query` argument, as the driver contract declares. - **Hooks are unchanged.** An `after*` hook still reads `input.options` as the caller's bag with the driver options merged on. - **Who moves.** A driver that read a key `DriverOptionsSchema` does not declare off its options argument: read the query from the `query` argument, or have the key declared on `DriverOptionsSchema` first. No driver in this repository reads one. From 6aaa0350bb42d2e4de85c8132785f971a24b32e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 17:12:47 +0000 Subject: [PATCH 7/7] fix(objectql): contract-review should-fix items for the declared driver options rule - the no-bag hook view is a copy, never the driver's bag itself - the hook PHASE paragraph names buildDriverOptions (the driver's set) and mergeDriverOptionsIntoHookView (the merge into the hook view) - the pass-through keys docblock says the bag's declared keys are the base - the four re-pointed engine.test.ts assertions hold the received keys to a subset of declaredDriverOptionKeys() - the changeset says undeclared keys, and qualifies which doors admit the schema's other declared keys Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../driver-options-declared-keys-only.md | 4 ++-- .../src/engine-driver-options.test.ts | 7 ++++-- .../objectql/src/engine-driver-options.ts | 7 ++++-- packages/objectql/src/engine-top-level.ts | 9 ++++---- packages/objectql/src/engine.test.ts | 23 +++++++++++++++---- packages/spec/src/data/hook.zod.ts | 4 +++- 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/.changeset/driver-options-declared-keys-only.md b/.changeset/driver-options-declared-keys-only.md index 0ea42b31d12..fb1a343ff99 100644 --- a/.changeset/driver-options-declared-keys-only.md +++ b/.changeset/driver-options-declared-keys-only.md @@ -10,7 +10,7 @@ Metadata-protocol: none The engine → driver hop is a closed contract, as the engine's own option bags are: every driver call the engine makes is handed the keys `DriverOptionsSchema` declares and no other key. - **Which calls.** `find`, `findOne`, `count`, `aggregate`, `insert`, `update` and `delete` (by id and multi-row), the reads and writes those verbs make along the way, and the raw `execute` door. -- **What arrives, unchanged.** `transaction`, `tenantId`, `tenantIds`, `timezone`, `preserveAudit` and `bypassTenantAudit` reach the driver exactly as before, and a caller's explicit value still wins. The schema's other declared keys pass when a caller sets them. The retired `timeout` tombstone is not handed on. -- **What stays with the engine.** The rest of a caller's engine option bag (the query keys, the write-observability callbacks and the other `Engine*Options` entries) is read by the engine and is no longer copied onto a driver's options. A driver reads the query from its `query` argument, as the driver contract declares. +- **What arrives, unchanged.** `transaction`, `tenantId`, `tenantIds`, `timezone`, `preserveAudit` and `bypassTenantAudit` reach the driver exactly as before, and a caller's explicit value still wins. The schema's other declared keys pass when a caller sets them at a door that admits them, which is `insert` and the raw `execute` door; `find`, `findOne`, `update` and `delete` admit only the six keys above at their own door. The retired `timeout` tombstone is not handed on. +- **What stays with the engine.** Undeclared keys in a caller's engine option bag stay with the engine and are no longer copied onto a driver's options. A driver reads the query from its `query` argument, as the driver contract declares. - **Hooks are unchanged.** An `after*` hook still reads `input.options` as the caller's bag with the driver options merged on. - **Who moves.** A driver that read a key `DriverOptionsSchema` does not declare off its options argument: read the query from the `query` argument, or have the key declared on `DriverOptionsSchema` first. No driver in this repository reads one. diff --git a/packages/objectql/src/engine-driver-options.test.ts b/packages/objectql/src/engine-driver-options.test.ts index 2811ed98e21..ca91341640e 100644 --- a/packages/objectql/src/engine-driver-options.test.ts +++ b/packages/objectql/src/engine-driver-options.test.ts @@ -239,11 +239,14 @@ describe('the after* hook view of input.options stays additive', () => { expect(seenCallback).toBe(callback); }); - it('merging adds the driver options and answers the caller\'s own bag when nothing is added', () => { + it('merging adds the driver options, answers the caller\'s own bag when nothing is added, and never answers the driver\'s bag itself', () => { const bag = { where: { a: 1 }, tenantId: ORG }; expect(mergeDriverOptionsIntoHookView(bag, pickDeclaredDriverOptions(bag))).toBe(bag); expect(mergeDriverOptionsIntoHookView(bag, { tenantId: ORG, timezone: TZ })).toEqual({ where: { a: 1 }, tenantId: ORG, timezone: TZ }); - expect(mergeDriverOptionsIntoHookView(undefined, { tenantId: ORG })).toEqual({ tenantId: ORG }); + const driverBag = { tenantId: ORG }; + const view = mergeDriverOptionsIntoHookView(undefined, driverBag); + expect(view).toEqual(driverBag); + expect(view).not.toBe(driverBag); expect(mergeDriverOptionsIntoHookView(bag, undefined)).toBe(bag); }); }); diff --git a/packages/objectql/src/engine-driver-options.ts b/packages/objectql/src/engine-driver-options.ts index 1330b39a08f..08ffacb3c17 100644 --- a/packages/objectql/src/engine-driver-options.ts +++ b/packages/objectql/src/engine-driver-options.ts @@ -171,14 +171,17 @@ export function pickDeclaredDriverOptions(bag: unknown): Record * only the hooks': the driver is handed `driverOptions` itself. * * Answers the caller's own bag, the same object, when the driver options add - * nothing to it, and `driverOptions` when there is no caller bag. + * nothing to it, and a COPY of `driverOptions` when there is no caller bag — + * never `driverOptions` itself, so the hooks' view and the driver's bag are + * never one object, whatever order a later edit puts a hook and a driver + * call in. */ export function mergeDriverOptionsIntoHookView( callerBag: unknown, driverOptions: Record | undefined, ): unknown { if (driverOptions === undefined) return callerBag; - if (callerBag === null || typeof callerBag !== 'object') return driverOptions; + if (callerBag === null || typeof callerBag !== 'object') return { ...driverOptions }; const bag = callerBag as Record; const addsNothing = Object.keys(driverOptions).every( (key) => Object.prototype.propertyIsEnumerable.call(bag, key) && bag[key] === driverOptions[key], diff --git a/packages/objectql/src/engine-top-level.ts b/packages/objectql/src/engine-top-level.ts index 86184945d2c..c037fd73c7f 100644 --- a/packages/objectql/src/engine-top-level.ts +++ b/packages/objectql/src/engine-top-level.ts @@ -275,10 +275,11 @@ const WIRE_ONLY_CANONICAL_SHAPES: Record = { /** * [#4371 option 2] The driver-option keys the engine forwards verbatim: on - * `find`/`findOne`/`update`/`delete` the option bag IS the base of the driver - * options (`buildDriverOptions(object, ctx, bag)`), which is how a caller's - * explicit `tenantId` / `bypassTenantAudit` reaches the driver (pinned in - * engine.test.ts). `count`/`aggregate` never forward the bag, so these keys + * `find`/`findOne`/`update`/`delete` the bag's DECLARED driver-option keys + * are the base of the driver options (`buildDriverOptions(object, ctx, bag)` + * keeps those and nothing else of the bag — `pickDeclaredDriverOptions`), + * which is how a caller's explicit `tenantId` / `bypassTenantAudit` reaches + * the driver (pinned in engine.test.ts). `count`/`aggregate` never forward the bag, so these keys * are deliberately NOT legal there — accepting them would be the exact * silently-ignored contract this gate exists to close. */ diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index b2a74464a72..6f744951817 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -4,6 +4,7 @@ import { ExpressionEngine } from '@objectstack/formula'; import { SchemaRegistry } from './registry'; import { HookTargetRebindError, HOOK_TARGET_REBIND_ERROR_CODE } from './hook-target-rebind-errors'; import type { IDataDriver } from '@objectstack/spec/contracts'; +import { declaredDriverOptionKeys } from './engine-driver-options'; // Mock the SchemaRegistry to avoid side effects between tests. // SchemaRegistry is now a per-instance class (one per ObjectQL engine), so the @@ -66,6 +67,20 @@ vi.mock('./registry', async () => { }); }); +/** + * The options argument a driver received holds declared driver-option keys + * only (a subset of `declaredDriverOptionKeys()`) — the hop rule + * `engine-driver-options.test.ts` pins on every verb. + */ +const declaredDriverOptionsOnly = { + $$typeof: Symbol.for('jest.asymmetricMatcher'), + asymmetricMatch: (options: unknown): boolean => + options === undefined + || (typeof options === 'object' && options !== null + && Reflect.ownKeys(options).every((key) => typeof key === 'string' && declaredDriverOptionKeys().has(key))), + toAsymmetricMatcher: () => 'DeclaredDriverOptionsOnly', +}; + describe('ObjectQL Engine', () => { let engine: ObjectQL; let mockDriver: IDataDriver; @@ -1618,7 +1633,7 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenLastCalledWith( 'sys_file', expect.objectContaining({ where: { id: { $in: ['file_a', 'file_b'] } } }), - expect.not.objectContaining({ context: expect.anything() }), + declaredDriverOptionsOnly, ); // The batched sys_file read is an expansion sub-read: the middleware // sees its `__expandRead` marker; the driver is handed declared @@ -1749,7 +1764,7 @@ describe('ObjectQL Engine', () => { object: 'user', where: { id: { $in: ['u1', 'u2'] } }, }), - expect.not.objectContaining({ context: expect.anything() }), + declaredDriverOptionsOnly, ); }); @@ -1786,7 +1801,7 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenLastCalledWith( 'sys_user', expect.objectContaining({ where: { id: { $in: ['u1'] } } }), - expect.not.objectContaining({ context: expect.anything() }), + declaredDriverOptionsOnly, ); }); @@ -2376,7 +2391,7 @@ describe('ObjectQL Engine', () => { expect.objectContaining({ where: { id: { $in: ['u1', 'u2'] } }, }), - expect.not.objectContaining({ context: expect.anything() }), + declaredDriverOptionsOnly, ); expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' }); expect(result[1].assignee).toEqual({ id: 'u1', name: 'Alice' }); diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 8052ab83d9f..5ef1a207d66 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -881,7 +881,9 @@ export const HookContextSchema = lazySchema(() => z.object({ * own options bag (`EngineQueryOptions` / `DataEngineInsertOptions` / * `EngineUpdateOptions` / `EngineDeleteOptions`) and only AFTER the `before*` * handlers return — before the driver call — merges the driver-facing keys - * onto it (`buildDriverOptions`: transaction, tenantId, timezone, …). So a + * onto it (`buildDriverOptions` produces the driver's set — transaction, + * tenantId, timezone, … — and `mergeDriverOptionsIntoHookView` merges that + * set into this view). So a * `before*` handler reads the CALLER's bag, `where` and `multi` included; an * `after*` handler reads the merged view. The merge is * ADDITIVE — it spreads the caller's bag and adds keys, never strips one — so