From 86c84f14da52155733869737c557df8b6c81d27c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:44:12 +0800 Subject: [PATCH 1/2] docs(spec): driver.find example shows canonical QueryAST keys, not wire spellings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @example taught filters/sort/top — keys a driver can never receive: the engine folds/rejects wire spellings before this layer (#4346, #4371), and QuerySchema declares where/orderBy/limit. Also dedupes the doubled @returns tag. Comment-only; no generated artifact reads @example (all 8 gates verified up to date). Co-Authored-By: Claude Fable 5 --- packages/spec/src/data/driver.zod.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/data/driver.zod.ts b/packages/spec/src/data/driver.zod.ts index 6f36f141dd..5c6e2d7623 100644 --- a/packages/spec/src/data/driver.zod.ts +++ b/packages/spec/src/data/driver.zod.ts @@ -436,15 +436,16 @@ export const DriverInterfaceSchema = lazySchema(() => z.object({ * Parsing the QueryAST is the responsibility of the driver implementation. * * @param object - The name of the object/table to query (e.g. 'account'). - * @param query - The structured QueryAST (filters, sorts, joins, pagination). + * @param query - The structured QueryAST (where, orderBy, fields, pagination). * @param options - Driver options. - * @returns Array of records. - * + * * @example + * // The engine hands drivers CANONICAL QueryAST keys only — wire spellings + * // (`filters`/`sort`/`top`) are folded away before this layer (#4371). * await driver.find('account', { - * filters: [['status', '=', 'active'], 'and', ['amount', '>', 500]], - * sort: [{ field: 'created_at', order: 'desc' }], - * top: 10 + * where: { status: 'active', amount: { $gt: 500 } }, + * orderBy: [{ field: 'created_at', order: 'desc' }], + * limit: 10 * }); * @returns Array of records. * MUST return `id` as string. MUST NOT return implementation details like `_id`. From 7a32d192c6fdab5c188493e4e98071d6e21c854b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:18:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(objectql,spec,metadata-protocol,servic?= =?UTF-8?q?e-queue):=20engine=20option=20bags=20are=20a=20closed=20contrac?= =?UTF-8?q?t=20=E2=80=94=20unknown=20keys=20throw=20(#4371=20option=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six engine methods now reject non-null option keys they do not execute, naming the legal set per method; cursor/distinct quote their #4286 tombstone; null stays a withdrawal. Legal sets = schema keys + documented extras (searchFields — now DECLARED on EngineQueryOptionsSchema, it was read and sent all along; onFieldsDropped on update; driver pass-through keys on the methods whose bag reaches driver options — count/aggregate forward nothing, so they reject those too). A drift pin holds the sets equal to the schemas. Same sweep also closes what the option-2 caller survey turned up: - bag object no longer overrides the resolved AST object ({...query, object} order fix); protocol refuses a contradicting POST-body object (400 QUERY_OBJECT_MISMATCH) and strips its own vocabulary (object/count/ tombstones/non-aggregate having) off the engine bag - nested expand ASTs reject the four wire-only spellings like the top level - $search/$searchFields engine reads removed (protocol normalizes to bare) - DbQueueAdapter purge/purgeFailed passed {id} — purge deleted NOTHING (throw swallowed into warn), purgeFailed always threw; both now pass {where: {id}}, and the test fake stops accepting the signature the real engine rejects - 19 test bags carrying the dead 'filters' key cleaned up (two were intent-carrying: a locale multi-update and an oidc dogfood assert that both silently scanned unfiltered) - driver.find JSDoc example shows canonical QueryAST keys Closes #4371 (option 1 landed in #4387). Co-Authored-By: Claude Fable 5 --- .../engine-rejects-unknown-option-keys.md | 54 ++++ content/docs/references/data/data-engine.mdx | 1 + packages/metadata-protocol/src/protocol.ts | 29 +++ .../src/engine-unknown-option.test.ts | 233 ++++++++++++++++++ .../src/engine-validation-locale.test.ts | 2 +- packages/objectql/src/engine.test.ts | 34 +-- packages/objectql/src/engine.ts | 155 +++++++++++- packages/objectql/src/protocol-data.test.ts | 34 +++ ...dc-authorization-code-flow.dogfood.test.ts | 4 +- .../src/db-queue-adapter.test.ts | 10 +- .../service-queue/src/db-queue-adapter.ts | 9 +- packages/spec/authorable-surface.json | 1 + packages/spec/src/data/data-engine.zod.ts | 9 + 13 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 .changeset/engine-rejects-unknown-option-keys.md create mode 100644 packages/objectql/src/engine-unknown-option.test.ts diff --git a/.changeset/engine-rejects-unknown-option-keys.md b/.changeset/engine-rejects-unknown-option-keys.md new file mode 100644 index 0000000000..98a269ca27 --- /dev/null +++ b/.changeset/engine-rejects-unknown-option-keys.md @@ -0,0 +1,54 @@ +--- +"@objectstack/objectql": patch +"@objectstack/spec": patch +"@objectstack/metadata-protocol": patch +"@objectstack/service-queue": patch +--- + +fix(objectql,spec,metadata-protocol,service-queue): engine option bags are now a closed contract — unknown keys throw instead of silently doing nothing (#4371 option 2) + +The engine declares `Engine*OptionsSchema` but never parses it at runtime, so +any option key outside the contract — a typo (`orderby`), a retired key +(`cursor`), a wire-protocol leftover (`object`, `count`), a key that only +works on other methods (`tenantId` on `count`) — rode along and was silently +ignored. All six methods now reject non-null unknown keys, naming the legal +set; retired keys (`cursor`/`distinct`) quote their #4286 tombstone; `null` +stays a withdrawal. + +Per-method legal keys = the method's schema keys plus the documented extras: +`searchFields` (now declared on `EngineQueryOptionsSchema` — it was read by +the engine's `$search` expansion and sent by the protocol layer all along), +`onFieldsDropped` on `update` (contract-declared write observability), and +the driver pass-through keys (`transaction`, `tenantId`, `tenantIds`, +`timezone`, `bypassTenantAudit`, `preserveAudit`) on `find`/`findOne`/ +`update`/`delete` — the methods whose bag actually reaches driver options. +`count`/`aggregate` never forward their bag, so pass-through keys there are +rejected rather than accepted-and-ignored. A drift pin holds the sets equal +to the schemas. + +Also closed in the same sweep: + +- A bag-level `object` key used to OVERRIDE the resolved object on the query + AST (`{ object, ...query }` spread order), splitting `ast.object` from the + table actually queried. The AST now keeps the resolved name; a direct call + passing `object` is rejected, and the protocol layer refuses a POST-body + `object` that contradicts the route (400 `QUERY_OBJECT_MISMATCH`) instead + of picking a winner. +- `findData` no longer leaks protocol-layer vocabulary (`object`, `count`, + `joins`, `windowFunctions`, `cursor`, `distinct`, non-aggregate `having`) + onto the engine bag. +- Nested expand ASTs (`expand: { rel: { sort } }`) reject the four wire-only + spellings exactly like the top-level bag (#4371 option 1 did the top level). +- The engine's OData-spelling reads (`$search`/`$searchFields`) are gone — + the protocol normalizes to the bare keys; a direct call passing them now + throws instead of half-working on one method. +- `DbQueueAdapter.purge`/`purgeFailed` passed `{ id }` — a key the engine + never read, so purge deleted NOTHING (each delete threw into a warn-level + catch) and purgeFailed always threw. Both now pass `{ where: { id } }`; + the test fake's `delete` no longer accepts the signature the real engine + rejects. + +Migration for direct engine callers (wire/HTTP callers are unaffected): pass +only the keys your method's `Engine*OptionsSchema` declares (plus the extras +above). Anything else previously did nothing — delete it, or move it to the +layer that owns it. diff --git a/content/docs/references/data/data-engine.mdx b/content/docs/references/data/data-engine.mdx index 4c319ca97a..b76ab05837 100644 --- a/content/docs/references/data/data-engine.mdx +++ b/content/docs/references/data/data-engine.mdx @@ -548,6 +548,7 @@ QueryAST-aligned query options for IDataEngine.find() operations | **top** | `number` | optional | | | **cursor** | `any` | optional | [REMOVED] `query.cursor` was removed in @objectstack/spec 17 (#4286, ADR-0049) — no driver ever implemented keyset pagination, so the cursor was accepted and ignored and every page came back identical (a caller looping "until hasMore is false" never terminates). Delete the key; `QueryBuilder.cursor()` was removed with it. Express the keyset as an ordinary `where` predicate on your sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` — which every driver executes with canonicalised comparands. A first-class cursor, if ever built, will be a response-minted opaque token, not this caller-built record. | | **search** | `{ query: string; fields?: string[]; fuzzy: boolean; operator: Enum<'and' \| 'or'>; … }` | optional | | +| **searchFields** | `string[]` | optional | | | **expand** | `Record; … }; … }>` | optional | | | **distinct** | `any` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (#4286, ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 9e2b995f1c..e715252516 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4434,6 +4434,35 @@ export class ObjectStackProtocolImplementation implements }; } + // [#4371 option 2] Strip the PROTOCOL-layer keys off the bag before it + // becomes the engine option bag — the engine now rejects option keys + // it does not execute, and these belong to this layer, not to it: + // - `object`: the POST-body convenience copy of the route object + // (reserved above so it is not read as a field filter). It used to + // ride the spread into the engine AST and OVERRIDE the resolved + // object, splitting `ast.object` from the table actually queried — + // a mismatch is refused, never resolved by picking a winner. + // - `count`: a response-shape flag this method consumed above. + // - QueryAST tombstones (`cursor`/`joins`/`windowFunctions`/ + // `distinct`): reserved at the wire gate so they are not read as + // field filters; on the wire they stay ignored-with-tombstone-docs + // (schema parse is where they 400), and they must not leak to the + // engine as junk. + // - `having`: consumed by the aggregate branch above; the find path + // cannot serve it. + if (options.object != null && options.object !== request.object) { + const err: any = new Error( + `Conflicting object: the route addresses '${request.object}' but the query body ` + + `says '${options.object}'. The body 'object' key is a convenience copy of the ` + + 'route object and must match it.', + ); + err.status = 400; + err.code = 'QUERY_OBJECT_MISMATCH'; + throw err; + } + for (const k of ['object', 'count', 'joins', 'windowFunctions', 'cursor', 'distinct', 'having']) { + delete options[k]; + } const records = await this.engine.find(request.object, options); // Pagination metadata. When a `limit` is present the response is a single // page, so `records.length` is the page size — NOT the match total. Run a diff --git a/packages/objectql/src/engine-unknown-option.test.ts b/packages/objectql/src/engine-unknown-option.test.ts new file mode 100644 index 0000000000..1850123298 --- /dev/null +++ b/packages/objectql/src/engine-unknown-option.test.ts @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #4371 option (2) — an engine option-bag key the engine does not execute is + * REJECTED at the entry point instead of silently ignored. + * + * The engine declares a query contract (`Engine*OptionsSchema`) but never + * parses it at runtime, so before this gate ANY key outside the contract — + * a typo (`orderby`), a retired key (`cursor`), a wire-protocol leftover + * (`object`, `count`), a key that only works on OTHER methods (`tenantId` on + * `count`) — rode along and was dropped without a trace. The per-method legal + * sets mirror the schemas plus the documented extras (`searchFields`, + * `onFieldsDropped`, the driver pass-through keys); the drift pin at the + * bottom keeps them from falling out of step with the spec. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + EngineQueryOptionsSchema, + EngineUpdateOptionsSchema, + EngineDeleteOptionsSchema, + EngineCountOptionsSchema, + EngineAggregateOptionsSchema, +} from '@objectstack/spec/data'; +import { ObjectQL, ENGINE_OPTION_KEY_SETS } from './engine.js'; + +const task = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + title: { name: 'title', type: 'text' as const }, + status: { name: 'status', type: 'text' as const }, + owner: { name: 'owner', type: 'lookup' as const, reference: 'person' }, + }, +}; +const person = { + name: 'person', + label: 'Person', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + }, +}; + +function makeDriver() { + const stores = new Map>>(); + const storeFor = (o: string) => { let s = stores.get(o); if (!s) { s = new Map(); stores.set(o, s); } return s; }; + const finds: any[] = []; + let nextId = 0; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and') return (v as any[]).every((w) => matches(row, w)); + if (k.startsWith('$')) continue; + const exp = (v && typeof v === 'object' && '$in' in (v as any)) ? (v as any).$in : v; + if (Array.isArray(exp)) { if (!exp.includes(row[k])) return false; } + else if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const run = (o: string, ast: any) => { + let rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + if (Array.isArray(ast?.orderBy) && ast.orderBy.length > 0) { + rows = [...rows].sort((a: any, b: any) => { + for (const { field, order } of ast.orderBy) { + const cmp = String(a?.[field] ?? '').localeCompare(String(b?.[field] ?? '')); + if (cmp !== 0) return order === 'desc' ? -cmp : cmp; + } + return 0; + }); + } + return typeof ast?.limit === 'number' && ast.limit > 0 ? rows.slice(0, ast.limit) : rows; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async find(o: string, ast: any) { finds.push(ast); return run(o, ast); }, + async findOne(o: string, ast: any) { finds.push(ast); return run(o, ast)[0] ?? null; }, + findStream() { throw new Error('ns'); }, + async create(o: string, data: any) { nextId += 1; const id = data.id ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; }, + async update(o: string, id: string, data: any) { const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error('nf'); const up = { ...cur, ...data, id }; s.set(id, up); return up; }, + async updateMany(o: string, ast: any, data: any) { let n = 0; for (const [id, row] of storeFor(o)) { if (!matches(row, ast?.where)) continue; storeFor(o).set(id, { ...row, ...data, id }); n += 1; } return n; }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async deleteMany(o: string, ast: any) { let n = 0; for (const [id, row] of [...storeFor(o)]) { if (!matches(row, ast?.where)) continue; storeFor(o).delete(id); n += 1; } return n; }, + async count(o: string, ast: any) { return run(o, ast).length; }, + async aggregate(o: string, ast: any) { return [{ n: run(o, ast).length }]; }, + async bulkCreate(o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, stores, finds }; +} + +describe('unknown engine option keys are rejected (#4371 option 2)', () => { + let engine: ObjectQL; + let finds: any[]; + let a: any; + + beforeEach(async () => { + engine = new ObjectQL(); + const mem = makeDriver(); + finds = mem.finds; + engine.registerDriver(mem.driver, true); + await engine.init(); + engine.registry.registerObject(task as any); + engine.registry.registerObject(person as any); + await engine.insert('person', { id: 'p1', name: 'P' }); + a = await engine.insert('task', { title: 'A', status: 'open', owner: 'p1' }); + await engine.insert('task', { title: 'B', status: 'done', owner: 'p1' }); + finds.length = 0; + }); + + // ── each method refuses a key it does not execute ─────────────────── + + it.each([ + ['find', () => engine.find('task', { bogus: 1 } as any)], + ['findOne', () => engine.findOne('task', { bogus: 1 } as any)], + ['update', () => engine.update('task', { title: 'Z' }, { where: { id: 'x' }, bogus: 1 } as any)], + ['delete', () => engine.delete('task', { where: { id: 'x' }, bogus: 1 } as any)], + ['count', () => engine.count('task', { bogus: 1 } as any)], + ['aggregate', () => engine.aggregate('task', { aggregations: [{ function: 'count', alias: 'n' }], bogus: 1 } as any)], + ])('%s rejects an unknown option, naming the legal set', async (method, call) => { + await expect(call()).rejects.toThrow( + new RegExp(`${method}\\('task'\\) does not recognise option 'bogus'.*Legal keys for ${method}:`, 's'), + ); + }); + + it('a typo like `orderby` is caught, not silently unsorted', async () => { + await expect(engine.find('task', { orderby: [{ field: 'title', order: 'asc' }] } as any)) + .rejects.toThrow(/'orderby'/); + expect(finds).toHaveLength(0); + }); + + it('several unknown keys are reported in ONE rejection', async () => { + await expect(engine.find('task', { bogus: 1, extra: 2 } as any)) + .rejects.toThrow(/options 'bogus'; 'extra'/); + }); + + // ── retired keys quote their tombstone ────────────────────────────── + + it.each(['cursor', 'distinct'])('%s is rejected with its #4286 tombstone', async (key) => { + await expect(engine.find('task', { [key]: 'x' } as any)) + .rejects.toThrow(/#4286, ADR-0049/); + }); + + // ── null stays a withdrawal ───────────────────────────────────────── + + it('a null-valued unknown key is a withdrawal, not a rejection', async () => { + const rows = await engine.find('task', { bogus: null } as any); + expect(rows).toHaveLength(2); + }); + + // ── the documented extras stay legal where they work ──────────────── + + it('driver pass-through keys (tenantId, bypassTenantAudit) pass on find/update/delete', async () => { + await engine.find('task', { tenantId: 't1', bypassTenantAudit: true } as any); + await engine.update('task', { title: 'A2' }, { where: { id: a.id }, tenantId: 't1' } as any); + await engine.delete('task', { where: { id: a.id }, bypassTenantAudit: true } as any); + }); + + it.each([ + ['count', () => engine.count('task', { tenantId: 't1' } as any)], + ['aggregate', () => engine.aggregate('task', { aggregations: [{ function: 'count', alias: 'n' }], tenantId: 't1' } as any)], + ])('%s rejects driver pass-through keys — its bag never reaches the driver options', async (_m, call) => { + await expect(call()).rejects.toThrow(/'tenantId'/); + }); + + it('onFieldsDropped passes on update (contract-declared write observability)', async () => { + await engine.update('task', { title: 'A3' }, { where: { id: a.id }, onFieldsDropped: () => {} } as any); + }); + + it('searchFields passes on find (read by the $search expansion)', async () => { + // Legality pin only — the $or/$contains expansion itself is covered by + // the search-filter tests; this mock driver does not evaluate it. + await expect(engine.find('task', { search: 'A', searchFields: ['title'] } as any)) + .resolves.toBeDefined(); + }); + + it('the OData spellings $search/$searchFields are unknown keys — wire-only, protocol folds them', async () => { + await expect(engine.find('task', { $search: 'A' } as any)).rejects.toThrow(/'\$search'/); + }); + + it('a stray `object` key is refused — it used to OVERRIDE the resolved object on the AST', async () => { + await expect(engine.find('task', { object: 'person' } as any)).rejects.toThrow(/'object'/); + expect(finds).toHaveLength(0); + }); + + // ── expand nested ASTs reject wire spellings too ──────────────────── + + it("expand: { owner: { sort } } is refused — nested wire spellings were silently dropped", async () => { + await expect( + engine.find('task', { expand: { owner: { object: 'person', sort: { name: 'asc' } } } } as any), + ).rejects.toThrow(/expand\['owner'\] on 'task' does not accept 'sort'.*Pass 'orderBy'/s); + }); + + it('expand with canonical nested keys works', async () => { + const rows = await engine.find('task', { + where: { id: a.id }, + expand: { owner: { object: 'person', fields: ['id', 'name'] } }, + } as any); + expect(rows[0].owner).toMatchObject({ id: 'p1', name: 'P' }); + }); + + // ── drift pin: legal sets stay glued to the spec schemas ──────────── + + it('each legal set covers its schema shape (minus tombstones) and only the documented extras', () => { + const TOMBSTONES = new Set(['cursor', 'distinct']); + const PASSTHROUGH = ['transaction', 'tenantId', 'tenantIds', 'timezone', 'bypassTenantAudit', 'preserveAudit']; + const expectSetMatches = ( + setName: string, + schema: { shape: Record }, + extras: string[], + ) => { + const legal = ENGINE_OPTION_KEY_SETS[setName]; + const schemaKeys = Object.keys(schema.shape).filter((k) => !TOMBSTONES.has(k)); + for (const k of schemaKeys) { + // `top` folds into `limit` before the gate and never reaches it. + if (k === 'top') continue; + expect(legal.has(k), `${setName} must accept schema key '${k}'`).toBe(true); + } + const documented = new Set([...schemaKeys, ...extras]); + for (const k of legal) { + expect(documented.has(k), `${setName} accepts '${k}' which neither the schema nor the documented extras declare`).toBe(true); + } + }; + expectSetMatches('find', EngineQueryOptionsSchema as any, PASSTHROUGH); + expectSetMatches('findOne', EngineQueryOptionsSchema as any, PASSTHROUGH); + expectSetMatches('update', EngineUpdateOptionsSchema as any, ['onFieldsDropped', ...PASSTHROUGH]); + expectSetMatches('delete', EngineDeleteOptionsSchema as any, PASSTHROUGH); + expectSetMatches('count', EngineCountOptionsSchema as any, []); + expectSetMatches('aggregate', EngineAggregateOptionsSchema as any, []); + }); +}); diff --git a/packages/objectql/src/engine-validation-locale.test.ts b/packages/objectql/src/engine-validation-locale.test.ts index fd6415f62f..efb8e701ff 100644 --- a/packages/objectql/src/engine-validation-locale.test.ts +++ b/packages/objectql/src/engine-validation-locale.test.ts @@ -130,7 +130,7 @@ describe('engine write path — validation messages honour ExecutionContext.loca ql.update( 'mes_settlement', { quota_hours: -3 }, - { multi: true, filters: [['name', '=', 'S1']], context: zhCN } as any, + { multi: true, where: { name: 'S1' }, context: zhCN } as any, ), ); expect(msg).toBe('定额工时必须大于或等于 0'); diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index dfa0b799cb..7f143f8a6e 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -202,7 +202,7 @@ describe('ObjectQL Engine', () => { // Mock Schema: Object uses default datasource vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', datasource: 'default', fields: {} }); - await engine.find('task', { filters: [] }); + await engine.find('task', {}); expect(mockDriver.find).toHaveBeenCalled(); expect(mockDriver2.find).not.toHaveBeenCalled(); @@ -212,7 +212,7 @@ describe('ObjectQL Engine', () => { // Mock Schema: Object uses 'mongo' datasource vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'log', datasource: 'mongo', fields: {} }); - await engine.find('log', { filters: [] }); + await engine.find('log', {}); expect(mockDriver.find).not.toHaveBeenCalled(); expect(mockDriver2.find).toHaveBeenCalled(); @@ -615,12 +615,12 @@ describe('ObjectQL Engine', () => { const lastFindOpts = () => (mockDriver.find as any).mock.calls.at(-1)?.[2]; it('find: context from the trailing options arg reaches the driver', async () => { - await engine.find('task', { filters: [] }, { context: { tenantId: 't-opts' } as any }); + await engine.find('task', {}, { context: { tenantId: 't-opts' } as any }); expect(lastFindOpts()).toMatchObject({ tenantId: 't-opts' }); }); it('find: context inside the query still works (legacy form)', async () => { - await engine.find('task', { filters: [], context: { tenantId: 't-query' } as any }); + await engine.find('task', { context: { tenantId: 't-query' } as any }); expect(lastFindOpts()).toMatchObject({ tenantId: 't-query' }); }); @@ -635,7 +635,7 @@ describe('ObjectQL Engine', () => { it('findOne accepts context via the trailing options arg', async () => { (mockDriver.findOne as any).mockResolvedValue({ id: '1' }); - await engine.findOne('task', { filters: [] }, { context: { tenantId: 't-fo' } as any }); + await engine.findOne('task', {}, { context: { tenantId: 't-fo' } as any }); expect((mockDriver.findOne as any).mock.calls.at(-1)?.[2]).toMatchObject({ tenantId: 't-fo' }); }); @@ -653,12 +653,12 @@ describe('ObjectQL Engine', () => { // honoring of the option lives in @objectstack/driver-sql, and the audit // hook + readonly whitelist in plugin.integration.test.ts. it('threads preserveAudit from the context into the driver options (#3493)', async () => { - await engine.find('task', { filters: [] }, { context: { preserveAudit: true } as any }); + await engine.find('task', {}, { context: { preserveAudit: true } as any }); expect(lastFindOpts()).toMatchObject({ preserveAudit: true }); }); it('does NOT set preserveAudit when the context omits it (opt-in)', async () => { - await engine.find('task', { filters: [] }, { context: { tenantId: 't' } as any }); + await engine.find('task', {}, { context: { tenantId: 't' } as any }); expect(lastFindOpts()?.preserveAudit).toBeUndefined(); }); }); @@ -681,27 +681,27 @@ describe('ObjectQL Engine', () => { it('threads accessible_org_ids as tenantIds under the group posture', async () => { (engine as any).setTenancyPostureProvider(() => 'group'); - await engine.find('task', { filters: [] }, { context: groupCtx }); + await engine.find('task', {}, { context: groupCtx }); expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a', tenantIds: ['org_a', 'org_b'] }); }); it('no provider (no enforcement layer) → equality only, never widened', async () => { - await engine.find('task', { filters: [] }, { context: groupCtx }); + await engine.find('task', {}, { context: groupCtx }); expect(lastFindOpts()?.tenantIds).toBeUndefined(); expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); }); it('isolated posture → equality only (the union is a group-only widening)', async () => { (engine as any).setTenancyPostureProvider(() => 'isolated'); - await engine.find('task', { filters: [] }, { context: groupCtx }); + await engine.find('task', {}, { context: groupCtx }); expect(lastFindOpts()?.tenantIds).toBeUndefined(); }); it('group with an absent/empty accessible set → equality only (fail toward isolation)', async () => { (engine as any).setTenancyPostureProvider(() => 'group'); - await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a', accessible_org_ids: [] } as any }); + await engine.find('task', {}, { context: { tenantId: 'org_a', accessible_org_ids: [] } as any }); expect(lastFindOpts()?.tenantIds).toBeUndefined(); - await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any }); + await engine.find('task', {}, { context: { tenantId: 'org_a' } as any }); expect(lastFindOpts()?.tenantIds).toBeUndefined(); }); @@ -723,13 +723,13 @@ describe('ObjectQL Engine', () => { vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'sys_license', tenancy: { enabled: false }, fields: {}, } as any); - await engine.find('sys_license', { filters: [] }, { context: { tenantId: 'org_admin' } as any }); + await engine.find('sys_license', {}, { context: { tenantId: 'org_admin' } as any }); expect(lastFindOpts()?.tenantId).toBeUndefined(); }); it('still stamps tenantId for objects without a tenancy declaration', async () => { vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any); - await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any }); + await engine.find('task', {}, { context: { tenantId: 'org_a' } as any }); expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); }); @@ -737,7 +737,7 @@ describe('ObjectQL Engine', () => { vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', tenancy: { enabled: true }, fields: {}, } as any); - await engine.find('task', { filters: [] }, { context: { tenantId: 'org_a' } as any }); + await engine.find('task', {}, { context: { tenantId: 'org_a' } as any }); expect(lastFindOpts()).toMatchObject({ tenantId: 'org_a' }); }); @@ -745,7 +745,7 @@ describe('ObjectQL Engine', () => { vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'sys_license', tenancy: { enabled: false }, fields: {}, } as any); - await engine.find('sys_license', { filters: [] }, { + await engine.find('sys_license', {}, { context: { tenantId: 'org_admin', timezone: 'Asia/Shanghai' } as any, }); expect(lastFindOpts()).toMatchObject({ timezone: 'Asia/Shanghai' }); @@ -761,7 +761,7 @@ describe('ObjectQL Engine', () => { // tenantId — deliberate cross-checks stay possible. await engine.find( 'sys_license', - { filters: [], tenantId: 'org_explicit' } as any, + { tenantId: 'org_explicit' } as any, { context: { timezone: 'UTC' } as any }, ); expect(lastFindOpts()).toMatchObject({ tenantId: 'org_explicit' }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index f747fb56bb..434f302871 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -11,6 +11,8 @@ import { EngineCountOptions, RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots, + QUERY_CURSOR_REMOVED, + QUERY_DISTINCT_REMOVED, type QueryAliasSlot, type DroppedFieldsEvent } from '@objectstack/spec/data'; @@ -154,6 +156,112 @@ const WIRE_ONLY_CANONICAL_SHAPES: Record = { expand: 'a record of { relationName: QueryAST }', }; +/** + * [#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 + * are deliberately NOT legal there — accepting them would be the exact + * silently-ignored contract this gate exists to close. + */ +const ENGINE_DRIVER_PASSTHROUGH_KEYS = [ + 'transaction', 'tenantId', 'tenantIds', 'timezone', 'bypassTenantAudit', 'preserveAudit', +] as const; + +/** + * [#4371 option 2] Per-method legal option keys. An option bag key outside + * the method's set is REJECTED at the entry point: the engine executes none + * of them, so the call would otherwise succeed with the option silently + * ignored — the `declared ≠ enforced` shape (PD #10) one layer below the + * wire-alias rejection above. + * + * Sources, in order: the method's `Engine*OptionsSchema` declared keys (minus + * the `retiredKey` tombstones `cursor`/`distinct`, which get their tombstone + * quoted instead of a generic rejection — the schema keeps them ONLY to carry + * that message, and this runtime path never parses); `searchFields` (read by + * `find` at the `$search` expansion, sent by the protocol layer); + * `onFieldsDropped` (`WriteObservabilityOptions` — contract-declared, + * unrepresentable in the serializable Zod schema); and the driver + * pass-through keys above. The alias spellings (`filter`/`top`) are folded + * and deleted BEFORE this check runs, so they never reach it. + * + * A drift pin in engine-unknown-option.test.ts asserts each set equals its + * schema's shape (minus tombstones, plus the documented extras) so a key + * added to the spec cannot be silently rejected here. + */ +const ENGINE_FIND_OPTION_KEYS: ReadonlySet = new Set([ + 'context', 'where', 'fields', 'orderBy', 'limit', 'offset', + 'search', 'searchFields', 'expand', + ...ENGINE_DRIVER_PASSTHROUGH_KEYS, +]); +const ENGINE_UPDATE_OPTION_KEYS: ReadonlySet = new Set([ + 'context', 'where', 'upsert', 'multi', 'returning', 'onFieldsDropped', + ...ENGINE_DRIVER_PASSTHROUGH_KEYS, +]); +const ENGINE_DELETE_OPTION_KEYS: ReadonlySet = new Set([ + 'context', 'where', 'multi', + ...ENGINE_DRIVER_PASSTHROUGH_KEYS, +]); +const ENGINE_COUNT_OPTION_KEYS: ReadonlySet = new Set(['context', 'where']); +const ENGINE_AGGREGATE_OPTION_KEYS: ReadonlySet = new Set([ + 'context', 'where', 'groupBy', 'aggregations', 'having', 'timezone', +]); + +/** Tombstoned option keys: rejected with the spec's own removal notice. */ +const ENGINE_RETIRED_OPTION_MESSAGES: Record = { + cursor: QUERY_CURSOR_REMOVED, + distinct: QUERY_DISTINCT_REMOVED, +}; + +/** + * The per-method legal key sets, exported for the drift pin ONLY + * (engine-unknown-option.test.ts asserts each set against its schema's shape, + * so a key added to the spec cannot be silently rejected here). Not a public + * API surface — consumers pass options, they do not read this table. + */ +export const ENGINE_OPTION_KEY_SETS: Readonly>> = { + find: ENGINE_FIND_OPTION_KEYS, + findOne: ENGINE_FIND_OPTION_KEYS, + update: ENGINE_UPDATE_OPTION_KEYS, + delete: ENGINE_DELETE_OPTION_KEYS, + count: ENGINE_COUNT_OPTION_KEYS, + aggregate: ENGINE_AGGREGATE_OPTION_KEYS, +}; + +/** + * Reject option-bag keys the engine does not execute (#4371 option 2). + * + * Runs AFTER `foldEngineOptionAliases`, so alias spellings are already folded + * away (or thrown on). `null`-valued keys pass — a `null` is a withdrawal + * carrying no intent a drop could lose, same rule as the fold. Retired keys + * (`cursor`/`distinct`) quote their tombstone. Everything else gets the legal + * key set, so the error carries the fix. + */ +function rejectUnknownEngineOptions( + object: string, + operation: string, + bag: object | undefined, + legal: ReadonlySet, +): void { + if (!bag) return; + let unknown: string[] | undefined; + for (const [key, value] of Object.entries(bag)) { + if (value == null || legal.has(key)) continue; + (unknown ??= []).push(key); + } + if (!unknown) return; + const details = unknown.map((k) => + ENGINE_RETIRED_OPTION_MESSAGES[k] ? `'${k}': ${ENGINE_RETIRED_OPTION_MESSAGES[k]}` : `'${k}'`, + ); + throw new Error( + `${operation}('${object}') does not recognise option${unknown.length > 1 ? 's' : ''} ` + + `${details.join('; ')}. The engine executes none of ${unknown.length > 1 ? 'them' : 'it'}, ` + + `so the call would succeed with the option silently ignored (#4371). ` + + `Legal keys for ${operation}: ${[...legal].sort().join(', ')}.`, + ); +} + /** * Fold the deprecated alias spellings of an engine option bag into their * canonical QueryAST keys, under the #3795/#4181 rule: an alias alone moves to @@ -2697,6 +2805,25 @@ export class ObjectQL implements IDataEngine { if (!objectSchema || !objectSchema.fields) return records; for (const [fieldName, nestedAST] of Object.entries(expand)) { + // [#4371] The nested AST is caller-authored too: a wire spelling inside + // `expand: { rel: { sort } }` used to be silently dropped exactly like + // the top-level bag (this loop reads only canonical keys). Same + // rejection, scoped to the four wire-only pairs — the nested shape is a + // QueryAST, not an option bag, so the option-key gate does not apply. + if (nestedAST && typeof nestedAST === 'object') { + for (const slot of ENGINE_WIRE_ONLY_SLOTS) { + for (const alias of slot.aliases) { + if ((nestedAST as Record)[alias] != null) { + throw new Error( + `expand['${fieldName}'] on '${objectName}' does not accept '${alias}': it is a wire ` + + `spelling of '${slot.canonical}', folded by the RPC/protocol layer — a direct engine ` + + `call bypasses that fold, so the value would be silently dropped, not applied. Pass ` + + `'${slot.canonical}' (${WIRE_ONLY_CANONICAL_SHAPES[slot.canonical] ?? 'the canonical QueryAST shape'}) instead.`, + ); + } + } + } + } const fieldDef = objectSchema.fields[fieldName]; // Skip if field not found or not a relationship type. @@ -2996,9 +3123,14 @@ export class ObjectQL implements IDataEngine { // folded — a direct call carrying one used to have it silently dropped // (#4371, three shipped instances in #4370). query = foldEngineOptionAliases(object, 'find', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); + rejectUnknownEngineOptions(object, 'find', query, ENGINE_FIND_OPTION_KEYS); this.logger.debug('Find operation starting', { object, query }); const driver = this.getDriver(object); - const ast: QueryAST = { object, ...query }; + // `object` LAST: the resolved name must win. Spread-first used to let a + // stray `query.object` overwrite it, splitting the AST's object from the + // table actually queried (#4371 option-2 survey) — every middleware and + // hook reading `ast.object` would have been lied to. + const ast: QueryAST = { ...query, object }; // Remove context from the AST — it's not a driver concern delete (ast as any).context; @@ -3013,10 +3145,14 @@ export class ObjectQL implements IDataEngine { // is intersected with the allowed set. All drivers already execute // `$or`/`$contains`, so this needs no driver changes. { - const _searchRaw = (ast as any).search ?? (ast as any).$search; + // The `$search`/`$searchFields` OData spellings are NOT read here: the + // protocol layer normalizes them to the bare keys before the engine + // (protocol.ts findData), and a direct engine call carrying one is an + // unknown option — rejected above, not silently dropped (#4371). + const _searchRaw = (ast as any).search; if (_searchRaw != null && _findSchema?.fields) { - const _reqFields = (ast as any).searchFields ?? (ast as any).$searchFields - ?? (typeof (ast as any).search === 'object' ? (ast as any).search?.fields : undefined); + const _reqFields = (ast as any).searchFields + ?? (typeof _searchRaw === 'object' ? _searchRaw?.fields : undefined); const _searchFilter = expandSearchToFilter(_searchRaw, { fields: _findSchema.fields as any, searchableFields: (_findSchema as any).searchableFields, @@ -3030,9 +3166,7 @@ export class ObjectQL implements IDataEngine { } } delete (ast as any).search; - delete (ast as any).$search; delete (ast as any).searchFields; - delete (ast as any).$searchFields; } const _findFormula = planFormulaProjection(_findSchema, ast.fields); if (_findFormula.projected) ast.fields = _findFormula.projected; @@ -3134,9 +3268,12 @@ export class ObjectQL implements IDataEngine { // Wire-only spellings are rejected, same as find() (#4371) — `sort` // matters here too: findOne({ sort }) means "first row of THIS order". query = foldEngineOptionAliases(objectName, 'findOne', query, ENGINE_QUERY_SLOTS, ENGINE_WIRE_ONLY_SLOTS); + rejectUnknownEngineOptions(objectName, 'findOne', query, ENGINE_FIND_OPTION_KEYS); this.logger.debug('FindOne operation', { objectName }); const driver = this.getDriver(objectName); - const ast: QueryAST = { object: objectName, ...query, limit: 1 }; + // `object` after the spread for the same reason as find(); `limit: 1` + // last — findOne is single-row by contract. + const ast: QueryAST = { ...query, object: objectName, limit: 1 }; // Remove context from the AST — it's not a driver concern delete (ast as any).context; @@ -3507,6 +3644,7 @@ export class ObjectQL implements IDataEngine { // `options.where` only, so an unfolded `{ filter }` left the AST with no // predicate at all and a `multi: true` update rewrote EVERY row. options = foldEngineOptionAliases(object, 'update', options, ENGINE_WHERE_SLOTS); + rejectUnknownEngineOptions(object, 'update', options, ENGINE_UPDATE_OPTION_KEYS); // Expand `{filter-placeholder}` values BEFORE the id is extracted (#3810). // The read path resolves them; without the same call here the SAME filter @@ -3905,6 +4043,7 @@ export class ObjectQL implements IDataEngine { // above (#4346): unfolded, a `multi: true` delete with `{ filter }` had no // predicate on its AST and emptied the table. options = foldEngineOptionAliases(object, 'delete', options, ENGINE_WHERE_SLOTS); + rejectUnknownEngineOptions(object, 'delete', options, ENGINE_DELETE_OPTION_KEYS); // Expand `{filter-placeholder}` values before the id is extracted — same // reasoning as update() above (#3810). @@ -4037,6 +4176,7 @@ export class ObjectQL implements IDataEngine { // Fold the `filter` alias into `where` (#4346) — the AST below reads // `query.where` only, so an unfolded `{ filter }` counted the whole table. query = foldEngineOptionAliases(object, 'count', query, ENGINE_WHERE_SLOTS); + rejectUnknownEngineOptions(object, 'count', query, ENGINE_COUNT_OPTION_KEYS); const driver = this.getDriver(object); // The AST must ride on the opCtx so the security/sharing middlewares can @@ -4117,6 +4257,7 @@ export class ObjectQL implements IDataEngine { // Fold the `filter` alias into `where` (#4346) — the AST below reads // `query.where` only, so an unfolded `{ filter }` aggregated every row. query = foldEngineOptionAliases(object, 'aggregate', query, ENGINE_WHERE_SLOTS); + rejectUnknownEngineOptions(object, 'aggregate', query, ENGINE_AGGREGATE_OPTION_KEYS); this.rejectCredentialAggregation(object, query); const driver = this.getDriver(object); this.logger.debug(`Aggregate on ${object} using ${driver.name}`, query); diff --git a/packages/objectql/src/protocol-data.test.ts b/packages/objectql/src/protocol-data.test.ts index 1f7d55958e..8ba3ce6aaf 100644 --- a/packages/objectql/src/protocol-data.test.ts +++ b/packages/objectql/src/protocol-data.test.ts @@ -26,6 +26,40 @@ describe('ObjectStackProtocolImplementation - Data Operations', () => { // ═══════════════════════════════════════════════════════════════ describe('findData', () => { + // [#4371 option 2] The engine rejects option keys it does not execute, + // so the protocol layer must not leak its OWN vocabulary onto the + // engine bag. These keys are reserved at the wire gate (not read as + // field filters) and consumed — or deliberately ignored — here. + it('strips protocol-layer keys (object/count/tombstones/having) off the engine bag', async () => { + await protocol.findData({ + object: 'task', + query: { + object: 'task', count: 'true', cursor: 'c1', distinct: 'true', + joins: [], windowFunctions: [], having: { n: 1 }, + where: { status: 'open' }, + }, + }); + const opts = mockEngine.find.mock.calls[0][1]; + expect(opts.where).toEqual({ status: 'open' }); + for (const k of ['object', 'count', 'joins', 'windowFunctions', 'cursor', 'distinct', 'having']) { + expect(opts[k], `'${k}' must not reach the engine bag`).toBeUndefined(); + } + }); + + it('a body `object` matching the route is a tolerated convenience copy', async () => { + await protocol.findData({ object: 'task', query: { object: 'task', limit: 5 } }); + const opts = mockEngine.find.mock.calls[0][1]; + expect(opts.object).toBeUndefined(); + expect(opts.limit).toBe(5); + }); + + it('a body `object` that CONTRADICTS the route is refused, never resolved by picking a winner', async () => { + await expect( + protocol.findData({ object: 'task', query: { object: 'sys_user', where: { a: 1 } } }), + ).rejects.toMatchObject({ status: 400, code: 'QUERY_OBJECT_MISMATCH' }); + expect(mockEngine.find).not.toHaveBeenCalled(); + }); + it('normalizes $search/$searchFields (OData) to bare search/searchFields, not implicit filters', async () => { await protocol.findData({ object: 'showcase_account', query: { $search: 'retail', $searchFields: ['name', 'industry'] } }); const opts = mockEngine.find.mock.calls[0][1]; diff --git a/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts b/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts index cd72849f7a..24856e365f 100644 --- a/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts +++ b/packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts @@ -134,7 +134,9 @@ describe('OIDC authorization-code flow (oauth-provider 1.7)', () => { const ql = await stack.kernel.getServiceAsync('objectql'); const rows = await ql.find('sys_oauth_access_token', { - filters: [['client_id', '=', CLIENT_ID]], + // `where`, not the wire spelling `filters` — that key was silently + // dropped (#4371), so this assertion used to scan the whole table. + where: { client_id: CLIENT_ID }, context: { isSystem: true }, }); const list = Array.isArray(rows) ? rows : (rows?.records ?? []); diff --git a/packages/services/service-queue/src/db-queue-adapter.test.ts b/packages/services/service-queue/src/db-queue-adapter.test.ts index 7508698a05..565b2ceb59 100644 --- a/packages/services/service-queue/src/db-queue-adapter.test.ts +++ b/packages/services/service-queue/src/db-queue-adapter.test.ts @@ -54,9 +54,15 @@ function makeFakeEngine() { return r; }, async delete(table: string, opts: any) { + // Real-engine contract: the target id lives at `where.id` — there is no + // top-level `id` option. The mock used to accept `opts.id`, a signature + // the real engine rejects, which is exactly how the adapter's broken + // `{ id }` bags stayed green (#4371 option-2 survey). + const id = opts?.where?.id; + if (id == null) throw new Error('Delete requires an ID or options.multi=true'); const t = tables.get(table) ?? []; - tables.set(table, t.filter((r) => r.id !== opts.id)); - return { id: opts.id }; + tables.set(table, t.filter((r) => r.id !== id)); + return { id }; }, }; } diff --git a/packages/services/service-queue/src/db-queue-adapter.ts b/packages/services/service-queue/src/db-queue-adapter.ts index 32a22d50db..53eab76c70 100644 --- a/packages/services/service-queue/src/db-queue-adapter.ts +++ b/packages/services/service-queue/src/db-queue-adapter.ts @@ -174,7 +174,12 @@ export class DbQueueAdapter implements IQueueService { context: SYSTEM_CTX, }); for (const row of rows ?? []) { - try { await this.engine.delete(QUEUE_TABLE, { id: row.id, context: SYSTEM_CTX }); } + // `where: { id }` — the engine's delete has no top-level `id` option. + // The old `{ id: row.id }` bag carried no predicate at all, so every + // purge delete threw "Delete requires an ID or options.multi=true" + // straight into this catch: purge logged a warn per row and deleted + // NOTHING (#4371 option-2 survey). + try { await this.engine.delete(QUEUE_TABLE, { where: { id: row.id }, context: SYSTEM_CTX }); } catch (err) { this.logger?.warn?.('DbQueueAdapter: purge delete failed', err as any); } } } @@ -220,7 +225,7 @@ export class DbQueueAdapter implements IQueueService { if (row.status !== 'dlq' && row.status !== 'failed') { throw new Error(`INVALID_STATE: cannot purge message in status=${row.status}`); } - await this.engine.delete(QUEUE_TABLE, { id: messageId, context: SYSTEM_CTX }); + await this.engine.delete(QUEUE_TABLE, { where: { id: messageId }, context: SYSTEM_CTX }); } // ── Worker lifecycle ───────────────────────────────────────────── diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index cde48c6e1a..6134eb0afc 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -3426,6 +3426,7 @@ "data/EngineQueryOptions:offset", "data/EngineQueryOptions:orderBy", "data/EngineQueryOptions:search", + "data/EngineQueryOptions:searchFields", "data/EngineQueryOptions:top", "data/EngineQueryOptions:where", "data/EngineUpdateOptions:context", diff --git a/packages/spec/src/data/data-engine.zod.ts b/packages/spec/src/data/data-engine.zod.ts index dd3f8e67a1..3eb05347c5 100644 --- a/packages/spec/src/data/data-engine.zod.ts +++ b/packages/spec/src/data/data-engine.zod.ts @@ -118,6 +118,15 @@ export const EngineQueryOptionsSchema = lazySchema(() => BaseEngineOptionsSchema /** Full-text search configuration */ search: FullTextSearchSchema.optional(), + /** + * Fields the `search` expansion may match against — intersected with the + * object's declared/derived searchable set (ADR-0061). Read by the engine's + * `$search` → cross-field `$or` expansion and sent by the protocol layer + * ever since; it was enforced but undeclared until #4371 (option 2) made + * the engine reject undeclared option keys. + */ + searchFields: z.array(z.string()).optional(), + /** * Recursive relation loading map (expand). *