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 e56580151a..4deaf7ac38 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, @@ -5674,8 +5678,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, }, ) ?? []; @@ -5969,6 +5976,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). * @@ -6017,7 +6093,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, @@ -6040,8 +6116,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 @@ -6167,7 +6245,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. @@ -6184,8 +6262,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 cd30ad7d1d..755a18cf46 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -35,7 +35,7 @@ "packages/core/src/security/resolve-authz-context.ts": 1, "packages/metadata-protocol/src/protocol.ts": 6, "packages/metadata-protocol/src/seed-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,