diff --git a/.changeset/driver-options-declared-keys-only.md b/.changeset/driver-options-declared-keys-only.md new file mode 100644 index 00000000000..fb1a343ff99 --- /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. 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 new file mode 100644 index 00000000000..ca91341640e --- /dev/null +++ b/packages/objectql/src/engine-driver-options.test.ts @@ -0,0 +1,252 @@ +// 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); + 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) { + 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, 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 }); + 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 88621412bec..08ffacb3c17 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,88 @@ 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 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 }; + 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 +255,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-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 04666dd12ed..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; @@ -1602,6 +1617,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 +1633,12 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenLastCalledWith( 'sys_file', expect.objectContaining({ where: { id: { $in: ['file_a', 'file_b'] } } }), - expect.objectContaining({ context: { __expandRead: true } }), + declaredDriverOptionsOnly, ); + // 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 +1753,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 +1764,7 @@ describe('ObjectQL Engine', () => { object: 'user', where: { id: { $in: ['u1', 'u2'] } }, }), - expect.objectContaining({ context: { __expandRead: true } }), + declaredDriverOptionsOnly, ); }); @@ -1775,7 +1801,7 @@ describe('ObjectQL Engine', () => { expect(mockDriver.find).toHaveBeenLastCalledWith( 'sys_user', expect.objectContaining({ where: { id: { $in: ['u1'] } } }), - expect.objectContaining({ context: { __expandRead: true } }), + declaredDriverOptionsOnly, ); }); @@ -2365,7 +2391,7 @@ describe('ObjectQL Engine', () => { expect.objectContaining({ where: { id: { $in: ['u1', 'u2'] } }, }), - expect.objectContaining({ context: { __expandRead: true } }), + declaredDriverOptionsOnly, ); expect(result[0].assignee).toEqual({ id: 'u1', name: 'Alice' }); expect(result[1].assignee).toEqual({ id: 'u1', name: 'Alice' }); 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..5ef1a207d66 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -881,11 +881,15 @@ 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 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.