From 4603beb80f122ffc9eb967d59b401c7978ea44dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 04:13:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec,objectql):=20IDataEngine.find/findOne?= =?UTF-8?q?=20accept=20the=20author=20state=20=E2=80=94=20engine=20fills?= =?UTF-8?q?=20SortNode.order's=20declared=20default=20(#6300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0122's core argument — the first key an author writes must default correctly — now holds on the engine's primary read entry. find/findOne's query parameter flips from EngineQueryOptionsParsed (z.infer) back to EngineQueryOptions (z.input), the author-state shape count already took, and ObjectQL fills the one consumed default (SortNode.order → 'asc') by running each authored sort node through SortNodeSchema before the QueryAST is built — recursively through expand — so the declared default stays single-sourced in packages/spec. Phase-1 measured delta between the two states: orderBy[].order (the one default anything consumes — every driver already coalesced its absence to 'asc'), the three inert search flags (read by no executor, deleted from the AST unread), and their recursion through expand. context is .partial() (no delta); where/fields/limit/offset/top carry no defaults or transforms. Widening for typed callers; the one behavior change is for type-bypassing callers only: a malformed sort node (retired 'direction' spelling, unknown key) is refused with SortNodeSchema's own prescription instead of silently dropped-or-honored per driver (#4721's class), matching the wire path's normalizeSortNodes. Closes #6300 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011SaEx5461eovGX7AS9aLDV --- .changeset/engine-author-state-find.md | 41 ++++ .../src/engine-author-state-query.test.ts | 224 ++++++++++++++++++ .../src/engine-filter-array-lowering.test.ts | 14 +- packages/objectql/src/engine.ts | 95 +++++++- .../src/hook-input-shape-contract.test.ts | 2 +- packages/spec/src/contracts/data-engine.ts | 18 +- scripts/query-options-erasure-baseline.json | 2 +- 7 files changed, 377 insertions(+), 19 deletions(-) create mode 100644 .changeset/engine-author-state-find.md create mode 100644 packages/objectql/src/engine-author-state-query.test.ts diff --git a/.changeset/engine-author-state-find.md b/.changeset/engine-author-state-find.md new file mode 100644 index 0000000000..09c9c601a1 --- /dev/null +++ b/.changeset/engine-author-state-find.md @@ -0,0 +1,41 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': minor +--- + +feat(spec,objectql): `IDataEngine.find`/`findOne` accept the author state — the engine fills `SortNode.order`'s declared default (#6300) + +ADR-0122's core argument — "the first key an author writes must default +correctly" — now holds on the engine's primary read entry: + +```ts +engine.find('task', { orderBy: [{ field: 'updated_at' }] }) // compiles; sorts asc +engine.find('task', { search: { query: 'renewal' } }) // compiles uncast +``` + +`find`/`findOne`'s `query` parameter flips from `EngineQueryOptionsParsed` +(`z.infer`) to `EngineQueryOptions` (`z.input`) — the same author-state shape +`count` already took. #6083 had pinned these two methods back to the parsed +state because the engine built its `QueryAST` by bare spread and filled no +default, so `order: undefined` would have reached drivers. The engine now runs +each authored sort node through `SortNodeSchema` (recursively through +`expand`) before the AST is built, so the declared default stays +single-sourced in `packages/spec`. + +**Widening, not breaking, for typed callers**: every previously-compiling call +still compiles (`z.infer` values are valid `z.input`), and no query's answer +changes — the measured driver-side status quo was that all drivers already +coalesced a missing `order` to `'asc'`, the schema's declared default. The +three defaulted `search` flags (`fuzzy`/`operator`/`highlight`) are +`[EXPERIMENTAL — not enforced]`, read by no executor, and deleted from the AST +before anything downstream sees it — so `search` is deliberately not parsed, +which also keeps the wire-tolerated comma-string `search.fields` shape +working. + +**One behavior change, for type-BYPASSING callers only**: a malformed sort +node smuggled past the type (`as any` / unparsed wire input) — the retired +`direction` spelling, or an unknown key — is now refused with +`SortNodeSchema`'s own prescription instead of being silently +dropped-or-honored per driver (one query, two orders — #4721's defect class; +the wire path's `normalizeSortNodes` already refused it). Write +`{ field, order: 'asc' | 'desc' }`, or omit `order` for the default. diff --git a/packages/objectql/src/engine-author-state-query.test.ts b/packages/objectql/src/engine-author-state-query.test.ts new file mode 100644 index 0000000000..a1bd513b41 --- /dev/null +++ b/packages/objectql/src/engine-author-state-query.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #6300 — `find`/`findOne` take the AUTHOR state (`z.input`), and the engine + * fills the defaults the schemas declare before the AST leaves it. + * + * ADR-0122's core argument is "the first key an author writes must default + * correctly". `engine.find(obj, { orderBy: [{ field: 'updated_at' }] })` is + * the natural spelling of "newest-ish first" — and until this card it did not + * compile: #6083 pinned `find`/`findOne` back to `EngineQueryOptionsParsed` + * (`z.infer`) because the engine built its `QueryAST` by bare spread and + * filled no default, so admitting the author state would have sent + * `order: undefined` to the driver. + * + * The measured driver-side status quo (part of #6300's own premise): every + * driver coalesces a missing `order` to `'asc'` — `sql-driver.ts` + * (`s.order || 'asc'`), `memory-driver.ts`, `mongodb-driver.ts`, + * `mongodb-aggregation.ts`, `remote-transport.ts`. So the filled `'asc'` + * changes no query's answer; what changes is that the AST now SAYS it, which + * is what these pins hold: + * + * 1. the author-state calls in this file COMPILE WITHOUT A CAST — that is + * the contract flip itself, pinned by `tsc`; + * 2. the driver receives `order: 'asc'`, not `undefined` — the engine fills + * the default rather than delegating it to per-driver tolerance; + * 3. direction is right: defaulted ≡ explicit `'asc'`, ≢ explicit `'desc'`; + * 4. the strictness the schema declares comes with its defaulting parse: a + * type-bypassing malformed sort node is refused with the schema's own + * prescription instead of being silently dropped-or-honored per driver + * (#4721's defect class, already refused on the wire path). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; +import { ObjectQL } from './engine.js'; + +const account = { + name: 'crm_account', + label: 'Account', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', 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 }, + }, +}; + +interface SeenRead { object: string; ast: any } + +/** Memory driver recording the AST of every read (same shape as the #4419 suite's). */ +function makeRecordingDriver() { + 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 reads: SeenRead[] = []; + 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 === '$or') return (v as any[]).some((w) => matches(row, w)); + if (k.startsWith('$')) continue; + if (v && typeof v === 'object' && '$in' in (v as any)) { + if (!(v as any).$in.map(String).includes(String(row[k]))) return false; + continue; + } + if (v && typeof v === 'object' && '$contains' in (v as any)) { + const needle = String((v as any).$contains).toLowerCase(); + if (!String(row[k] ?? '').toLowerCase().includes(needle)) return false; + continue; + } + const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; + 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)); + const ord = Array.isArray(ast?.orderBy) ? ast.orderBy : []; + if (ord.length > 0) { + rows = [...rows].sort((a: any, b: any) => { + for (const { field, order } of ord) { + 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) { reads.push({ object: o, ast }); return run(o, ast); }, + async findOne(o: string, ast: any) { reads.push({ object: o, ast }); return run(o, ast)[0] ?? null; }, + async create(o: string, data: Record) { + nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row; + }, + async update(o: string, id: string, data: Record) { + const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`); + const up = { ...cur, ...data, id }; s.set(id, up); return up; + }, + async delete(o: string, id: string) { return storeFor(o).delete(id); }, + async count(o: string, ast: any) { return run(o, ast).length; }, + async bulkCreate(o: string, rows: Record[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {}, + }; + return { driver, reads }; +} + +describe('find/findOne accept the author state and the engine fills the declared defaults (#6300)', () => { + let engine: ObjectQL; + let reads: SeenRead[]; + + beforeEach(async () => { + engine = new ObjectQL(); + const mem = makeRecordingDriver(); + reads = mem.reads; + engine.registerDriver(mem.driver, true); + await engine.init(); + engine.registry.registerObject(account); + engine.registry.registerObject(person); + const alice = await engine.insert('person', { name: 'Alice' }); + const bob = await engine.insert('person', { name: 'Bob' }); + // Names chosen so ascending ≠ descending ≠ insertion order. + await engine.insert('crm_account', { name: 'Beta', owner: bob.id }); + await engine.insert('crm_account', { name: 'Alpha', owner: alice.id }); + await engine.insert('crm_account', { name: 'Gamma', owner: alice.id }); + reads.length = 0; + }); + + // ── (1) The contract flip, pinned by the compiler ──────────────────────── + // Every call in this block is UNCAST. Under #6083's `...Parsed` parameter + // none of them compiled — `orderBy[].order` was required to write. The + // `IDataEngine`-typed alias pins the spec contract, not just the class. + + it('an orderBy without `order` compiles against IDataEngine and sorts ascending', async () => { + const dataEngine: IDataEngine = engine; + const rows = await dataEngine.find('crm_account', { orderBy: [{ field: 'name' }] }); + expect(rows.map((r: any) => r.name)).toEqual(['Alpha', 'Beta', 'Gamma']); + }); + + it('an object-form `search` without the flag keys compiles uncast and matches', async () => { + // `EngineQueryOptionsParsed['search']` required `fuzzy`/`operator`/ + // `highlight` (parse-time defaults); the author state makes them + // optional — which is the truth, since no executor reads them (#4286). + const dataEngine: IDataEngine = engine; + const rows = await dataEngine.find('crm_account', { search: { query: 'Beta' } }); + expect(rows.map((r: any) => r.name)).toEqual(['Beta']); + }); + + // ── (2) The engine fills the default — `undefined` stops reaching drivers ─ + + it("the driver receives order: 'asc', not undefined", async () => { + await engine.find('crm_account', { orderBy: [{ field: 'name' }] }); + const { ast } = reads.at(-1)!; + expect(ast.orderBy).toEqual([{ field: 'name', order: 'asc' }]); + }); + + it('a nested expand query is the same authoring surface, filled on its own read', async () => { + await engine.find('crm_account', { + where: { name: 'Alpha' }, + expand: { owner: { object: 'person', orderBy: [{ field: 'name' }] } }, + }); + const personRead = reads.find((r) => r.object === 'person'); + expect(personRead).toBeTruthy(); + expect(personRead!.ast.orderBy).toEqual([{ field: 'name', order: 'asc' }]); + }); + + // ── (3) Direction, predicted first ─────────────────────────────────────── + // Prediction (written before execution): the defaulted spelling behaves as + // the schema's declared `'asc'` — identical to explicit-asc, and the exact + // reverse of explicit-desc on this tie-free fixture. + + it("defaulted ≡ explicit 'asc', ≢ explicit 'desc'", async () => { + const defaulted = await engine.find('crm_account', { orderBy: [{ field: 'name' }] }); + const explicitAsc = await engine.find('crm_account', { orderBy: [{ field: 'name', order: 'asc' }] }); + const explicitDesc = await engine.find('crm_account', { orderBy: [{ field: 'name', order: 'desc' }] }); + expect(defaulted.map((r: any) => r.name)).toEqual(explicitAsc.map((r: any) => r.name)); + expect(defaulted.map((r: any) => r.name)).toEqual([...explicitDesc.map((r: any) => r.name)].reverse()); + expect(explicitDesc.map((r: any) => r.name)).toEqual(['Gamma', 'Beta', 'Alpha']); + }); + + it('findOne: an order-less orderBy is a legal #4419 predicate and answers the FIRST-ascending row', async () => { + const dataEngine: IDataEngine = engine; + const row = await dataEngine.findOne('crm_account', { orderBy: [{ field: 'name' }] }); + expect(row?.name).toBe('Alpha'); + const { ast } = reads.at(-1)!; + expect(ast.orderBy).toEqual([{ field: 'name', order: 'asc' }]); + expect(ast.limit).toBe(1); + }); + + // ── (4) The schema's strictness rides with its defaulting parse ────────── + // These callers bypass the type (`as unknown as EngineQueryOptions` — the + // #4918 spelling for a DELIBERATELY off-contract probe), which is the only + // way these shapes can occur. Before #6300 the engine forwarded them + // verbatim and each driver decided alone: memory honored `direction`, + // SQL/Mongo silently dropped it and sorted ascending — one query, two + // orders (#4721's class). + + it("the retired `direction` spelling is refused with the schema's rename prescription", async () => { + const offContract = { orderBy: [{ field: 'name', direction: 'desc' }] } as unknown as EngineQueryOptions; + await expect(engine.find('crm_account', offContract)).rejects.toThrow(/order/); + }); + + it('an unknown sort-node key is refused by name, not silently dropped', async () => { + const offContract = { orderBy: [{ field: 'name', frobnicate: true }] } as unknown as EngineQueryOptions; + await expect(engine.find('crm_account', offContract)).rejects.toThrow(/frobnicate/); + }); + + it('an explicit `order` is never clobbered by the fill', async () => { + const rows = await engine.find('crm_account', { orderBy: [{ field: 'name', order: 'desc' }] }); + expect(rows.map((r: any) => r.name)).toEqual(['Gamma', 'Beta', 'Alpha']); + const { ast } = reads.at(-1)!; + expect(ast.orderBy).toEqual([{ field: 'name', order: 'desc' }]); + }); +}); diff --git a/packages/objectql/src/engine-filter-array-lowering.test.ts b/packages/objectql/src/engine-filter-array-lowering.test.ts index 3b9d373cb1..9d64bd56c1 100644 --- a/packages/objectql/src/engine-filter-array-lowering.test.ts +++ b/packages/objectql/src/engine-filter-array-lowering.test.ts @@ -29,19 +29,21 @@ import { describe, it, expect, beforeEach } from 'vitest'; import type { EngineAggregateOptions, EngineCountOptions, - EngineQueryOptionsParsed, + EngineQueryOptions, } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; /** * [#4918] `FilterArray` on `where` is off-contract BY DECLARATION, and these - * tests exist to drive it: `EngineQueryOptionsParsed.where` is a `FilterCondition` / + * tests exist to drive it: `EngineQueryOptions.where` is a `FilterCondition` / * `Record< string, unknown >`, which an array is not assignable to, because * `FilterArray` is INPUT-ONLY authoring sugar the spec deliberately excludes * (#5285). So a test that hands the engine one has to say so, and - * `as unknown as EngineQueryOptionsParsed` is how: it names the contract being + * `as unknown as EngineQueryOptions` is how: it names the contract being * bypassed, keeps the rest of the call type-checked, and greps as an - * intentional act — none of which a bare `as any` does. + * intentional act — none of which a bare `as any` does. (#6300 flipped the + * find/findOne parameter from `EngineQueryOptionsParsed` to the author-state + * `EngineQueryOptions`; the cast target follows the contract it names.) * * Deliberately NOT used for the malformed-COMPARAND cases below * (`{ stage: { $nin: 'won' } }`). Those are ordinary objects that `tsc` @@ -49,8 +51,8 @@ import { ObjectQL } from './engine.js'; * reason the runtime gate this file pins has to exist. Erasing them would hide * that they are type-legal, which is the point. */ -const asFilterArrayQuery = (where: unknown): EngineQueryOptionsParsed => - ({ where }) as unknown as EngineQueryOptionsParsed; +const asFilterArrayQuery = (where: unknown): EngineQueryOptions => + ({ where }) as unknown as EngineQueryOptions; const deal = { name: 'deal', diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 210be7b76d..5bb7ba5042 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1,9 +1,13 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { AsyncLocalStorage } from 'node:async_hooks'; -import { QueryAST, HookContext, ServiceObject } from '@objectstack/spec/data'; +import { QueryAST, QueryInput, HookContext, ServiceObject } from '@objectstack/spec/data'; +// [#6300] The defaulting node schema `fillQueryAstDefaults` runs author input +// through — the declared `.default()` stays in `packages/spec`, the engine +// only invokes it. +import { SortNodeSchema } from '@objectstack/spec/data'; import { - EngineQueryOptionsParsed, + EngineQueryOptions, DataEngineInsertOptions, EngineUpdateOptions, EngineDeleteOptions, @@ -5571,8 +5575,11 @@ export class ObjectQL implements IObjectQLEngine { referenceObject, { where, - ...(nestedAST.fields ? { fields: nestedAST.fields as any } : {}), - ...(nestedAST.orderBy ? { orderBy: nestedAST.orderBy as any } : {}), + // [#6300] The `as any` these two carried is gone: `find` takes the + // author state now, and the parsed nodes a `QueryAST` holds are + // valid author input (a present `order` is legal to write). + ...(nestedAST.fields ? { fields: nestedAST.fields } : {}), + ...(nestedAST.orderBy ? { orderBy: nestedAST.orderBy } : {}), context: { ...(execCtx ?? {}), __expandRead: true } as ExecutionContext, }, ) ?? []; @@ -5866,6 +5873,75 @@ export class ObjectQL implements IObjectQLEngine { delete (ast as any).searchFields; } + /** + * [#6300] Fill the author-state defaults the query schemas declare, so the + * AST handed to middlewares, hooks and drivers is the PARSED state + * `QueryAST` (a `z.infer` type) promises. + * + * ADR-0122 made `EngineQueryOptions` the author state (`z.input`): a key + * with a declared `.default()` is optional to write. `find`/`findOne` kept + * demanding the parsed state anyway (#6083 pinned them back) because the + * engine built its AST by bare spread and filled no default — `order: + * undefined` would have ridden straight to the driver. This is the filling. + * Each defaulting node is run through ITS OWN schema rather than + * hand-assigning values, so a default declared in `packages/spec` stays the + * single source of truth: + * + * - `orderBy[]` nodes through `SortNodeSchema` — fills `order: 'asc'`, the + * query path's one declared default. Measured before the flip: every + * driver already coalesces a missing `order` to `'asc'` (`sql-driver.ts` + * `s.order || 'asc'`, `memory-driver.ts`, `mongodb-driver.ts`, + * `remote-transport.ts` likewise), so the filled value changes no query's + * answer — it makes the AST say what the drivers were already assuming. + * Parsing also applies the node's declared strictness to type-BYPASSING + * callers: an unknown sort key, or the retired `direction` spelling, is + * now refused with the schema's own prescription instead of silently + * dropped-or-honored per driver (#4721's defect class) — the same refusal + * `normalizeSortNodes` already makes on the wire path. + * - `expand` values recurse: a nested query is the same authoring surface. + * No driver reads `ast.expand` (the engine expands post-fetch), and the + * nested read that executes re-enters `find()` — which fills again — so + * the recursion keeps the AST's type honest without a cast. + * + * `search` is deliberately NOT parsed, though `FullTextSearchSchema` carries + * three flag defaults (`fuzzy`/`operator`/`highlight`). Two measurements + * decide it. First, nothing can ever read them off the AST: no executor + * reads the flags at all (#4286 — the ADR-0061 expansion reads only `query` + * + `fields`), and {@link expandSearchOnAst} deletes `search` from the AST + * before middlewares, hooks or the driver see it, so a filled value would be + * constructed and then discarded unread. Second, parsing would REFUSE input + * the engine deliberately accepts: the wire path hands this method + * `search.fields` in the comma-STRING shape (and the `q` spelling) that + * `resolveSearchFields`/`normalizeSearch` tolerate by design — pinned in + * `query-expression-conformance.test.ts` — while the schema declares + * `fields: string[]`. The type-level gap this leaves (author-state `search` + * inside a `QueryAST`-typed value, until the key is deleted a few lines + * later) is covered by the same single cast as `expand`, below. + * + * `where`/`fields`/`limit`/`offset`/`top` carry no `.default()` or + * `.transform()` (pinned in `filter.zod.ts`'s own docs) and are not parsed — + * the cost is one small-object parse per authored sort node / search + * config, only when the key is present. + */ + private fillQueryAstDefaults>( + query: T, + ): T & Pick & { expand?: Record } { + const out: Record = { ...query }; + if (Array.isArray(out.orderBy)) { + out.orderBy = out.orderBy.map((node) => SortNodeSchema.parse(node)); + } + if (out.expand != null && typeof out.expand === 'object') { + const expand: Record = {}; + for (const [field, nested] of Object.entries(out.expand)) { + expand[field] = this.fillQueryAstDefaults(nested as QueryInput); + } + out.expand = expand; + } + // The one cast in the flip: `orderBy`/`expand` are rebuilt above; `search` + // is claimed-but-not-parsed, per the doc — deleted from the AST unread. + return out as T & Pick & { expand?: Record }; + } + /** * Refuse a `findOne` that selects nothing in particular (#4419). * @@ -5914,7 +5990,7 @@ export class ObjectQL implements IObjectQLEngine { ); } - async find(object: string, query?: EngineQueryOptionsParsed, options?: EngineReadOptions): Promise { + async find(object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { object = this.resolveObjectName(object); // Normalize the alias spellings (`filter`→`where`, `top`→`limit`) by the // spec's slot table — the driver AST only understands the canonical keys, @@ -5937,8 +6013,10 @@ export class ObjectQL implements IObjectQLEngine { // ADR-0122 the caller-supplied `context` is the AUTHOR state (every key // optional) while `QueryAST` carries the parsed one, so spreading it in and // removing it a line later would type the AST with a context it never holds. + // [#6300] The rest of the bag is author state too now — the defaults its + // schemas declare are filled here, before anything downstream reads the AST. const { context: _findContext, ...findQuery } = query ?? {}; - const ast: QueryAST = { ...findQuery, object }; + const ast: QueryAST = { ...this.fillQueryAstDefaults(findQuery), object }; // Plan formula projection: rewrite ast.fields to drop virtual formula // names and inject their dependencies, so the driver returns the raw @@ -6064,7 +6142,7 @@ export class ObjectQL implements IObjectQLEngine { * * Fires the same `beforeFind`/`afterFind` hooks as `find` (#3195). */ - async findOne(objectName: string, query?: EngineQueryOptionsParsed, options?: EngineReadOptions): Promise { + async findOne(objectName: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise { objectName = this.resolveObjectName(objectName); // Same alias fold as find() (#4346). Without it, `findOne({ filter })` // matched the first row of the WHOLE table rather than the predicate. @@ -6081,8 +6159,9 @@ export class ObjectQL implements IObjectQLEngine { // last — findOne is single-row by contract. // Same reason as find(): the caller's `context` is the author state and the // AST carries the parsed one, so it leaves before the AST is typed. + // [#6300] And the same default-filling as find(), for the same reason. const { context: _findOneContext, ...findOneQuery } = query ?? {}; - const ast: QueryAST = { ...findOneQuery, object: objectName, limit: 1 }; + const ast: QueryAST = { ...this.fillQueryAstDefaults(findOneQuery), object: objectName, limit: 1 }; // Plan formula projection (same as find): rewrite ast.fields so the driver // returns the raw dependency fields, then evaluate formulas after fetch. diff --git a/packages/objectql/src/hook-input-shape-contract.test.ts b/packages/objectql/src/hook-input-shape-contract.test.ts index e861f446ac..364d87cefa 100644 --- a/packages/objectql/src/hook-input-shape-contract.test.ts +++ b/packages/objectql/src/hook-input-shape-contract.test.ts @@ -114,7 +114,7 @@ describe('[#5273] a bulk write carries no `ast` on `input`', () => { const { engine } = await boot(); engine.registerHook('beforeFind', async (ctx: any) => { seen.push(ctx.input); }, { object: 'task' }); - // No `as any` on the options: `find(object, query?: EngineQueryOptionsParsed)` + // No `as any` on the options: `find(object, query?: EngineQueryOptions)` // already infers an empty query, and erasing it would add a site to the // #4918 query-options ratchet (`check:query-options-erasure`) for no gain — // this call is in-contract, not a deliberate off-contract probe. diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index 954aab32f4..34dedbdea5 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -2,7 +2,7 @@ import { BaseEngineOptions, - EngineQueryOptionsParsed, + EngineQueryOptions, DataEngineInsertOptions, EngineUpdateOptions, EngineDeleteOptions, @@ -164,8 +164,18 @@ export interface IDataEngine { * permission-set loader among them — had to reach it through `any`, which is * exactly the erasure this issue is sweeping. `query.context` remains * supported; when both are given, `options.context` wins. + * + * [#6300] `query` is the AUTHOR state (`z.input`, ADR-0122): a key with a + * declared `.default()` — `orderBy[].order` — is optional to write, exactly + * as on `count`'s `EngineCountOptions`. #6083 had pinned these two methods + * back to the parsed state because the engine built its `QueryAST` by spread + * without filling any default; the engine now runs each defaulting node + * through its own schema before the AST is built (ObjectQL's + * `fillQueryAstDefaults`), so `find(obj, { orderBy: [{ field: 'updated_at' }] })` + * compiles and sorts ascending — the schema's declared default, and the same + * value every driver already coalesced a missing `order` to. */ - find(objectName: string, query?: EngineQueryOptionsParsed, options?: BaseEngineOptions): Promise; + find(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; /** * Read the ONE record the query selects, or `null`. * @@ -179,8 +189,10 @@ export interface IDataEngine { * * No ordering is imposed when the caller supplies none: `findOne` promises * *a* matching record, never a position in a sequence (#4363). + * + * [#6300] `query` is the author state (`z.input`), same as `find` above. */ - findOne(objectName: string, query?: EngineQueryOptionsParsed, options?: BaseEngineOptions): Promise; + findOne(objectName: string, query?: EngineQueryOptions, options?: BaseEngineOptions): Promise; insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise; update(objectName: string, data: any, options?: EngineUpdateOptions & WriteObservabilityOptions): Promise; delete(objectName: string, options?: EngineDeleteOptions): Promise; diff --git a/scripts/query-options-erasure-baseline.json b/scripts/query-options-erasure-baseline.json index ad475bb005..7c0704f998 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -36,7 +36,7 @@ "packages/metadata-protocol/src/protocol.ts": 6, "packages/metadata-protocol/src/seed-loader.ts": 3, "packages/metadata/src/loaders/database-loader.ts": 3, - "packages/objectql/src/engine.ts": 9, + "packages/objectql/src/engine.ts": 8, "packages/plugins/plugin-approvals/src/approval-service.ts": 10, "packages/plugins/plugin-approvals/src/approver-org-scope.ts": 2, "packages/plugins/plugin-approvals/src/lifecycle-hooks.ts": 4,