diff --git a/.changeset/15124-action-engine-facade-find-query-envelope.md b/.changeset/15124-action-engine-facade-find-query-envelope.md new file mode 100644 index 00000000000..ef93af3ad41 --- /dev/null +++ b/.changeset/15124-action-engine-facade-find-query-envelope.md @@ -0,0 +1,93 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +--- + +**BREAKING for action handlers** — `ActionEngineFacade.find` takes the engine's query ENVELOPE; the bare-filter parameter shape is withdrawn (#15124) + +Clause-②: yes (narrowing) + +`ctx.engine.find(object, query)` now takes `EngineQueryOptions` — the same +options bag `IDataEngine.find` and ObjectQL's own `engine.find` take, named by +identity rather than restated. **One platform, one query shape.** + +### Migration — FROM → TO + +| You wrote | Write instead | +| --- | --- | +| `ctx.engine.find('task', { status: 'open' })` | `ctx.engine.find('task', { where: { status: 'open' } })` | +| `ctx.engine.find('task', { amount: { $gt: 100 } })` | `ctx.engine.find('task', { where: { amount: { $gt: 100 } } })` | +| `ctx.engine.find('task', {})` | unchanged — an empty envelope is still the unfiltered read | + +The rewrite is lossless and mechanical: the filter moves under `where`, verbatim. +`tsc --noEmit` over your handlers finds every unmigrated call — see below. + +### Why the shape was withdrawn rather than the bar closed + +Until now this parameter was the `where` HALF of a query while every other +`find` on the platform took the whole envelope, and the runtime wrapped what it +was given. That made the most natural spelling the wrong one, silently: an +author who passed the engine's own envelope reached the engine as +`{ where: { where: … } }` — a filter on a field named `where` — which matches no +row and resolves to `[]` with **no error at all**. A handler that made the +mistake ran to completion over zero rows for as long as it shipped, and its own +hand-written test double, written to the same belief, passed every assertion. +Because an empty `{}` skipped the wrap, one unfiltered read kept working under +either belief, so a dead handler looked partially alive. + +Refusing `where` at the top level instead — intersecting the old parameter with +`{ where?: never }` — was rejected: it asserts a vocabulary fact the spec +declares nowhere, reserving the field name `where` across every customer's data +model to buy one parameter's compile-time check. Aligning the parameter removes +the ambiguity at its root and reserves nothing. + +### What the new declaration refuses, measured + +If your handler is typed with the published `ActionHandlerContext`, a bare filter +no longer type-checks on **either** path you can reach it by: + +- an object literal (`{ status: 'completed' }`) fails the excess-property check — + a field name is not an envelope key; +- a filter held in a `FilterCondition` variable fails **TS2559** — every envelope + key is optional, so a bag of field names has no property in common with it. + +The envelope's own keys are typed too: `where: 'a = b'`, `fields: 'id,subject'` +and `limit: '50'` are each refused. + +**If your handler is NOT typed with it** — a handler in an `objectstack.config.js` +/ `.mjs`, one annotated with your own copy of the context type, or a `(ctx: any)` +handler — nothing above reaches you, so the facade refuses the withdrawn shape at +**runtime** instead, before the engine, with the same prescription: + +``` +find('task') was given a key 'status' the query envelope does not carry. +ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare +filter — move the filter under `where`: find(object, { where: { … } }). +Envelope keys: context, cursor, distinct, expand, fields, limit, offset, +orderBy, search, searchFields, top, where. +``` + +⚠️ **That refusal matters most for a filter whose value is `null`.** The engine's +own unknown-option check exempts a `null` value, because on an option bag a +`null` is a withdrawal. On a filter it is the "rows with no X" idiom, so +`{ deleted_at: null }` would have been dropped unexecuted and the read would have +widened to **every row** — including the ones you were excluding — with no error +at all. It is refused instead. + +### What this opens + +`fields`, `orderBy`, `limit`, `offset` and `expand` are reachable from an action +handler for the first time — under the old parameter there was nowhere to carry +them. A caller-supplied `context` is **ignored**: this facade is trusted and +context-less by design, and the runtime stamps its own elevated +`ExecutionContext` last. Do not write one — it reads as authorization and is +none. + +### Checking a migrated handler + +Do not settle for "it still resolves". A handler that had been passing the +envelope was returning `[]` on **every** call, so a suite written against the +mistake passes and the row count is the only witness. Re-run each migrated +handler against seeded data and assert it returns the rows its filter selects. + + diff --git a/content/docs/ui/actions.mdx b/content/docs/ui/actions.mdx index 3f2021af153..56545f994c8 100644 --- a/content/docs/ui/actions.mdx +++ b/content/docs/ui/actions.mdx @@ -170,19 +170,36 @@ already `completed` — is not stamped, so nothing overwrites the handler's valu and nothing strips it either, and the action silently replaces the real completion timestamp with "now". That is why the snippet sends `status` alone. - -**`ctx.engine.find(object, filter)` takes a filter, not a query.** The second -argument is the `where` half only — `{ status: 'completed' }`, operators -(`{ amount: { $gt: 100 } }`), `$and` / `$or` / `$not` — and the runtime wraps -it in `where` itself. Passing an ObjectQL envelope -(`{ where: { status: 'completed' } }`) raises no error: it becomes -`{ where: { where: … } }`, matches no row, and returns `[]`. An empty filter -(`{}`) is passed through unwrapped, so the one unfiltered read works under -either reading and a handler can look partially alive. The parameter is typed -`FilterCondition` (`ActionEngineFacade` in `@objectstack/spec/ui`), which -refuses a primitive or a mistyped `$and` / `$or` / `$not` but still admits -`where` as a key — the sentence above is the contract, and a hand-written test -double must honour it too. + +**`ctx.engine.find(object, query)` takes the engine's query envelope.** The +second argument is the same options bag `engine.find` takes everywhere else — +the filter goes under `where`, and `fields`, `orderBy`, `limit`, `offset` and +`expand` mean what they mean on the engine. One platform, one query shape. + +```typescript +await ctx.engine.find('todo_task', { where: { status: 'completed' } }); +await ctx.engine.find('todo_task', { where: { amount: { $gt: 100 } }, fields: ['id', 'subject'], limit: 50 }); +await ctx.engine.find('todo_task', {}); // the unfiltered read +``` + +The parameter is typed `EngineQueryOptions` (`ActionEngineFacade` in +`@objectstack/spec/ui`), so if you annotate `ctx` with the published +`ActionHandlerContext` a bare filter is a **compile error** at the call site — +`{ status: 'completed' }` has nowhere to land, and neither does a filter held in +a `FilterCondition` variable. A hand-written test double must honour the envelope +too. + +If you do **not** annotate it — a handler in an `objectstack.config.js` / +`.mjs`, your own copy of the context type, or `(ctx: any)` — the facade refuses +the bare filter at **runtime** instead, naming the stray key and prescribing the +same fix. That runtime refusal is what stops `{ deleted_at: null }` from being +dropped unexecuted and quietly widening the read to every row. + +**Upgrading?** This parameter used to be the `where` half on its own, and the +runtime wrapped it. `find(o, f)` becomes `find(o, { where: f })`; an unfiltered +`find(o, {})` is unchanged. The old shape had the trap the other way round: +writing the engine's own envelope produced `{ where: { where: … } }`, which +matched no row and returned `[]` with no error at all. diff --git a/examples/app-todo/src/actions/task.handlers.ts b/examples/app-todo/src/actions/task.handlers.ts index e98bcf7e7f5..3792158b350 100644 --- a/examples/app-todo/src/actions/task.handlers.ts +++ b/examples/app-todo/src/actions/task.handlers.ts @@ -38,6 +38,11 @@ import type { ActionHandlerContext } from '@objectstack/spec/ui'; // written against the published type at all. The declaration now says // `string | string[]` (#15117), so the copy is gone and this example // type-checks against exactly the types a real app gets. +// +// And the copy's `query` bag turned out to be the shape the platform kept: +// #15124 withdrew the bare-filter parameter and `ctx.engine.find` now takes the +// ENGINE's query envelope — `find(object, { where: … })`, the same options bag +// `engine.find` takes anywhere else. One platform, one query shape. /** * Mark a single task as complete. @@ -106,7 +111,9 @@ export async function massCompleteTasks(ctx: ActionHandlerContext): Promise { const { engine } = ctx; - const completed = await engine.find('todo_task', { status: 'completed' }); + // [#15124] The filter goes under `where` — the second argument is the + // engine's query envelope, not the `where` half on its own. + const completed = await engine.find('todo_task', { where: { status: 'completed' } }); const ids = completed.map((r) => r.id as string); if (ids.length > 0) { await engine.delete('todo_task', ids); @@ -135,6 +142,7 @@ export async function setReminder(ctx: ActionHandlerContext): Promise { /** Export tasks to CSV format */ export async function exportTasksToCSV(ctx: ActionHandlerContext): Promise { const { engine } = ctx; + // An EMPTY envelope is still the unfiltered read — unchanged by #15124. const tasks = await engine.find('todo_task', {}); const header = 'subject,status,priority,category,due_date'; const rows = tasks.map((t) => diff --git a/packages/runtime/src/action-body-identity.test.ts b/packages/runtime/src/action-body-identity.test.ts index f1d18223a03..5c03cc400a8 100644 --- a/packages/runtime/src/action-body-identity.test.ts +++ b/packages/runtime/src/action-body-identity.test.ts @@ -36,9 +36,12 @@ import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; * `ctx.api` binding is exercised end-to-end. */ function makeSharingEngine(extra: Record = {}) { - const writes: Array<{ op: string; object: string; context: any }> = []; - const gate = (op: string, object: string, context: any) => { - writes.push({ op, object, context }); + // [#15124] `where` is recorded as well as `context`, so the "the caller's + // predicate must survive" case below can assert the PREDICATE instead of + // asserting that an entry exists. + const writes: Array<{ op: string; object: string; context: any; where?: unknown }> = []; + const gate = (op: string, object: string, context: any, where?: unknown) => { + writes.push({ op, object, context, where }); if (!context?.isSystem && !context?.userId) { throw new Error(`FORBIDDEN: insufficient privileges to ${op} ${object}`); } @@ -58,7 +61,7 @@ function makeSharingEngine(extra: Record = {}) { return { ok: true }; }, async find(object: string, options?: any) { - gate('find', object, options?.context); + gate('find', object, options?.context, options?.where); // [#16370] The by-id pre-load has to be ANSWERED: an action door now // refuses a row-scoped invocation whose caller-scope subject load did // not deliver the row, so a rig that answered every read with `[]` @@ -121,7 +124,9 @@ describe('#3914 — ctx.engine (buildActionEngineFacade)', () => { await engine.insert('crm_case', { subject: 'x' }); await engine.update('crm_case', 'case_1', { status: 'closed' }); await engine.delete('crm_case', 'case_1'); - await engine.find('crm_case', { status: 'open' }); + // [#15124] The envelope, not a bare filter — the withdrawn shape is now + // refused by the arm itself, so this call would throw if left as it was. + await engine.find('crm_case', { where: { status: 'open' } }); expect(ql.writes.map((w: any) => w.op)).toEqual(['insert', 'update', 'delete', 'find']); for (const w of ql.writes) { @@ -138,10 +143,14 @@ describe('#3914 — ctx.engine (buildActionEngineFacade)', () => { it('still passes the caller filter on find (context is additive, not a replacement)', async () => { const ql = makeSharingEngine(); const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' }); - await engine.find('crm_case', { status: 'open' }); + await engine.find('crm_case', { where: { status: 'open' } }); expect(ql.writes[0].context).toMatchObject({ isSystem: true, userId: 'u1' }); - // the caller's predicate must survive alongside the injected context - expect((ql.writes as any)[0]).toBeDefined(); + // [#15124] The caller's predicate must survive alongside the injected + // context — asserted on the PREDICATE. This line used to read + // `expect(ql.writes[0]).toBeDefined()`, which is true of any recorded + // call whatever the arm did with the filter, so the one thing the case + // is named for was the one thing it did not check. + expect(ql.writes[0].where).toEqual({ status: 'open' }); }); }); diff --git a/packages/runtime/src/action-engine-facade-find-envelope.test.ts b/packages/runtime/src/action-engine-facade-find-envelope.test.ts new file mode 100644 index 00000000000..a6be5b7e434 --- /dev/null +++ b/packages/runtime/src/action-engine-facade-find-envelope.test.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15124] `ActionEngineFacade.find` passes the engine's QUERY ENVELOPE + * through — the double-wrap is gone. + * + * ## What this pins, and why the declaration's own pin is not enough + * + * The spec half (`packages/spec/src/ui/action-params.test.ts`) pins what the + * DECLARATION admits and refuses, in the tsc channel. It cannot pin what the + * runtime does with the value, and the defect this card closes lived exactly + * there: the arm built the envelope itself, so an author who wrote the + * engine's own envelope reached `ql.find` as `{ where: { where: … } }` — a + * filter on a field named `where`, which matches no row and resolves to `[]` + * with no error at all. A type change alone would have left that arm free to + * keep wrapping, and the two halves would have disagreed in silence: exactly + * the shape #14175 found and could not close. + * + * So the assertions here are about the ARGUMENT the engine actually received, + * not about the rows that came back. A pin that only checked "rows came back" + * is what the original defect passed: the reporting app's own hand-written + * double read `query.where` and agreed with the mistake all the way down. + * + * ## The three clauses + * + * 1. **Pass-through, verbatim.** Every envelope key an author writes reaches + * `ql.find` under its own name — `where` as `where`, and `fields`, + * `orderBy`, `limit` beside it. Under the old arm the whole bag landed + * nested under `where` and the projection/paging keys were unreachable from + * a handler at all. + * 2. **No wrap, and no second `where`.** The negative half of clause 1, stated + * separately because it is the one an accidental re-wrap would break while + * clause 1 stayed green. + * 3. **`context` is the facade's.** The envelope carries `context` because + * every engine option bag does, but this facade is trusted and context-less + * by design (#3914, ADR-0096) — the elevated context it builds wins over a + * caller-supplied one. That is a security-shaped property of a spread + * ORDER, which is one edit away from silently inverting. + * + * @see packages/runtime/src/action-execution.ts — `buildActionEngineFacade`. + * @see packages/spec/src/ui/action-params.zod.ts — the member doc of record. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { buildActionEngineFacade, ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION } from './action-execution.js'; + +const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined }; + +/** + * An engine double that RECORDS the options bag its `find` was handed. Its + * `delete` is bound to the real engine's dispatch contract through the + * producer's own predicate rather than a mirrored `if` + * (`scripts/check-engine-double-contract.mjs`). + */ +function makeEngine(rows: Array> = []) { + const found: Array<{ object: string; options: Record | undefined }> = []; + const ql: any = { + found, + async insert(_object: string, data: Record) { + return { id: (data as Record)?.id ?? 'rec_new' }; + }, + async find(object: string, options?: Record) { + found.push({ object, options }); + return rows; + }, + async count(_object: string, _options?: Record) { + return rows.length; + }, + async delete(object: string, options?: Record) { + assertEngineDeleteDispatch(options); + return { ok: true, object }; + }, + }; + return ql; +} + +describe('#15124 — ActionEngineFacade.find passes the engine query envelope through', () => { + it('hands every envelope key to the engine under its own name', async () => { + const ql = makeEngine([{ id: 'tsk_1' }]); + const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' }); + + await engine.find('todo_task', { + where: { status: 'completed' }, + fields: ['id', 'subject'], + orderBy: [{ field: 'due_date', order: 'asc' }], + limit: 50, + }); + + const [call] = ql.found; + expect(call.object).toBe('todo_task'); + expect(call.options.where).toEqual({ status: 'completed' }); + expect(call.options.fields).toEqual(['id', 'subject']); + expect(call.options.orderBy).toEqual([{ field: 'due_date', order: 'asc' }]); + expect(call.options.limit).toBe(50); + }); + + it('does NOT wrap — the filter the author wrote under `where` stays one level deep', async () => { + const ql = makeEngine(); + const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' }); + + await engine.find('todo_task', { where: { status: 'completed' } }); + + const where = ql.found[0].options.where as Record; + // The defect this card closes, stated as an assertion: the old arm + // produced `{ where: { where: { status: … } } }`, which matched no row. + expect(where).not.toHaveProperty('where'); + expect(where).toEqual({ status: 'completed' }); + }); + + it('the unfiltered read stays unfiltered — `{}` carries no `where` at all', async () => { + const ql = makeEngine(); + const engine = buildActionEngineFacade(deps, ql, { userId: 'u1' }); + + await engine.find('todo_task', {}); + + expect(ql.found[0].options).not.toHaveProperty('where'); + }); + + it('stamps the facade\'s OWN elevated context, and a caller-supplied `context` does not displace it', async () => { + const ql = makeEngine(); + const engine = buildActionEngineFacade(deps, ql, { userId: 'u1', tenantId: 'org_acme' }); + + await engine.find('todo_task', { + where: { status: 'open' }, + context: { userId: 'someone_else', isSystem: false }, + } as never); + + const context = ql.found[0].options.context as Record; + // The facade is trusted and context-less by design: what a caller put + // in the envelope reads as authorization and is none. + expect(context).toBeDefined(); + expect(context.userId).not.toBe('someone_else'); + expect(context.isSystem).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// The UNTYPED channel, through a REAL engine +// --------------------------------------------------------------------------- + +/** + * Everything above pins the ARGUMENT, against a double. That is the right + * instrument for "the envelope goes through", and the wrong one for the + * question this block asks, which is what a caller still on the WITHDRAWN + * shape actually experiences — because the answer is produced by the engine, + * and a double is free to be kinder than one. + * + * ## Why there is an untyped channel at all + * + * `buildActionEngineFacade` returns `any`, so the published declaration binds + * a caller only where the caller opted into it. Three populations do not: + * a handler in a JS config (`objectstack.config.js` / `.mjs` are accepted + * spellings — `packages/cli/src/utils/config.ts`), a handler annotated with a + * LOCAL copy of the context type (the pattern #15117 measured in this repo's + * own example), and a `(ctx: any)` handler. ⛔ Metadata `type: 'script'` bodies + * are NOT in this set: the sandbox `ScriptContext` exposes `api`, never + * `engine`. + * + * ## What the engine does with a bare filter, and why one half was silent + * + * `ObjectQL.find` refuses option keys it does not execute (#4371) — but its + * refusal deliberately EXEMPTS a `null` value, because on an option bag a + * `null` is a withdrawal carrying no intent a drop could lose. On a FILTER + * that rule is exactly wrong: `{ deleted_at: null }` is the "rows with no X" + * idiom, and dropping it silently returns EVERY row — including the ones the + * author was excluding — to a caller whose next line is often a delete. + * + * Before #15124 the facade wrapped its argument, so no filter key ever reached + * that exemption. Removing the wrap without a refusal here would have opened + * the silent path, which is why the arm now judges its own parameter against + * the envelope's key set. The four rows below are that boundary, measured end + * to end rather than argued. + */ + +const PROBE_OBJECT = { + name: 'probe_task', + label: 'Probe Task', + fields: { + id: { type: 'text', label: 'Id' }, + status: { type: 'text', label: 'Status' }, + deleted_at: { type: 'datetime', label: 'Deleted at' }, + }, +} as any; + +/** + * A real `ObjectQL` over a real driver, seeded with three rows. + * + * sqlite `:memory:` rather than `@objectstack/driver-memory`: #5704 migrated + * this project's test backends to it and froze the memory driver's consumer + * set, which `check:driver-memory-census` holds to a ruled ledger. A new + * binding there would need a maintainer ruling, and nothing about this pin + * needs that driver — what has to be REAL here is the ENGINE, because the + * null-exemption this block is about is the engine's. + */ +async function makeRealEngine() { + const engine = new ObjectQL(); + engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as any, true); + await engine.init(); + engine.registry.registerObject(PROBE_OBJECT, 'test'); + await engine.syncSchemas?.(); + + const ctx = { isSystem: true } as any; + // t3 carries a non-null `deleted_at`, so a filter that is HONOURED returns + // two rows and a filter that is DROPPED returns three. Without it the + // silent-drop row would be indistinguishable from a correct answer. + await engine.insert('probe_task', { id: 't1', status: 'open', deleted_at: null }, { context: ctx }); + await engine.insert('probe_task', { id: 't2', status: 'completed', deleted_at: null }, { context: ctx }); + await engine.insert('probe_task', { id: 't3', status: 'open', deleted_at: '2026-01-01T00:00:00.000Z' }, { context: ctx }); + + return engine; +} + +/** Drive a call and hand back what it rejected with, or `undefined`. */ +async function rejection(run: Promise): Promise { + return run.then(() => undefined, (e: unknown) => e); +} + +describe('#15124 — the withdrawn shape on the UNTYPED channel, through a real engine', () => { + let engine: any; + beforeAll(async () => { engine = await makeRealEngine(); }); + + it('control — the envelope selects, through the real engine', async () => { + const facade = buildActionEngineFacade(deps, engine, { userId: 'u1' }); + + const rows = await facade.find('probe_task', { where: { status: 'completed' } }); + + expect(rows.map((r: any) => r.id)).toEqual(['t2']); + }); + + it('control — the empty envelope is still the unfiltered read', async () => { + const facade = buildActionEngineFacade(deps, engine, { userId: 'u1' }); + + const rows = await facade.find('probe_task', {}); + + expect(rows.map((r: any) => r.id).sort()).toEqual(['t1', 't2', 't3']); + }); + + it('REFUSES the withdrawn bare filter, and the refusal carries the `where` prescription', async () => { + const facade = buildActionEngineFacade(deps, engine, { userId: 'u1' }); + + const err = await rejection(facade.find('probe_task', { status: 'completed' })); + + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain(ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION); + // The offending key is NAMED — a prescription with no subject sends the + // reader back to a diff to find out which key it meant. + expect((err as Error).message).toContain("'status'"); + }); + + it('REFUSES a NULL-VALUED bare filter too — the row the engine\'s null exemption used to swallow', async () => { + const facade = buildActionEngineFacade(deps, engine, { userId: 'u1' }); + + const err = await rejection(facade.find('probe_task', { deleted_at: null })); + + // ⭐ THIS is the row the review failed the first cut on. Without the + // arm's own refusal the engine's `value == null` exemption lets the key + // through unexecuted and the call RESOLVES WITH ALL THREE ROWS — `t3`, + // the one the author was excluding, included. A `toThrow()` with no + // message assertion would not have caught it either: the shape that + // must never come back is ROWS. + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain(ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION); + expect((err as Error).message).toContain("'deleted_at'"); + expect(Array.isArray(err)).toBe(false); + }); + + it('the refusal is loud INSTEAD of reading, not as well as — nothing was queried', async () => { + const facade = buildActionEngineFacade(deps, engine, { userId: 'u1' }); + let reached = 0; + const counting = new Proxy(engine, { + get(target, prop, recv) { + if (prop === 'find') return async (...args: unknown[]) => { reached += 1; return (target as any).find(...args); }; + return Reflect.get(target, prop, recv); + }, + }); + const countingFacade = buildActionEngineFacade(deps, counting, { userId: 'u1' }); + + await rejection(countingFacade.find('probe_task', { deleted_at: null })); + + expect(reached).toBe(0); + // ...and the control proves the counter can move at all. + await countingFacade.find('probe_task', { where: { status: 'completed' } }); + expect(reached).toBe(1); + expect(facade).toBeDefined(); + }); +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index c9b6c637e77..769ce082534 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -16,6 +16,9 @@ */ import { validateActionParams, type ActionSession, type ResolvedActionParam } from '@objectstack/spec/ui'; +// [#15124] The facade's `find` judges its own parameter against the SAME +// declaration its type names, so the refusal and the type cannot drift apart. +import { EngineQueryOptionsSchema } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it @@ -1465,6 +1468,88 @@ export function buildActionApi(_deps: ActionExecutionDeps, ql: any, ec: any): an } } +/** + * The sentence a caller still on the WITHDRAWN bare-filter shape must read + * (#15124). Exported because the wording IS the contract here: this refusal is + * a plain `Error` carrying no ADR-0112 `code`/`status` — the same shape its + * sibling {@link ENGINE_DELETE_REJECT_MESSAGE} has — so a bare `toThrow()` + * would stay green against any unnamed `Error` at all, and the pin has to + * compare text. + */ +// The card id stays in this comment and out of the string: a runtime message +// reaches authors and operators who have no tracker to resolve `#NNNN` with +// (#15124, and `check:doc-authoring` enforces it). +export const ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION = + 'ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare filter — ' + + 'move the filter under `where`: find(object, { where: { … } }).'; + +/** + * The envelope's own key set, read off the DECLARATION rather than restated. + * + * Resolved on first use, never at module load: `EngineQueryOptionsSchema` is a + * `lazySchema` proxy whose whole purpose is to defer building its closures, and + * touching `.shape` here would build them for every process that imports this + * module whether or not an action ever runs. + */ +let actionEngineFindEnvelopeKeys: ReadonlySet | undefined; +function findEnvelopeKeys(): ReadonlySet { + return (actionEngineFindEnvelopeKeys ??= new Set( + Object.keys((EngineQueryOptionsSchema as unknown as { shape: Record }).shape), + )); +} + +/** + * Refuse the shape #15124 withdrew, loudly, BEFORE the engine sees it. + * + * ## Why the engine's own refusal is not enough + * + * `ObjectQL.find` already rejects option keys it does not execute (#4371) — + * but that check deliberately exempts a `null` VALUE, because on an option bag + * a `null` is a withdrawal carrying no intent a drop could lose. On a FILTER + * the same rule is exactly wrong: `{ deleted_at: null }` is the "rows with no + * X" idiom, so the key is dropped, the read widens to every row, and the call + * resolves. Measured on a real engine over three seeded rows, the excluded row + * came back with the others. + * + * Until this card the facade WRAPPED its argument, so no filter key ever + * reached that exemption; passing the envelope through without this guard is + * what would have opened the path. A handler's next line after such a read is + * routinely a delete, so this is the silent-data-loss class, not a DX nit. + * + * ## Why the key set comes from the schema + * + * The declared parameter type is `EngineQueryOptions`. Reading the same + * schema's shape here makes the compile-time refusal and the runtime refusal + * one fact with one source: a key the type rejects is a key this rejects, and a + * key the engine grows in the spec is legal in both on the same day. + * + * ⚠️ MEASURED DELTA, deliberate: the engine's own `find` set additionally + * carries six driver pass-through keys (`transaction`, `tenantId`, + * `tenantIds`, `timezone`, `bypassTenantAudit`, `preserveAudit`) that + * `EngineQueryOptions` does not declare. They stay refused here. Three of them + * are tenancy escape hatches, this facade is trusted and context-less by + * design, and no typed caller can write any of them — so agreeing with the + * TYPE is both the narrower and the fail-closed reading. Retired keys + * (`cursor`, `distinct`) are in the shape and pass through on purpose: the + * engine answers them with their tombstone, which is the better message. + */ +function assertActionEngineFindEnvelope(object: string, query: Record | undefined): void { + if (!query) return; + const legal = findEnvelopeKeys(); + let stray: string[] | undefined; + for (const key of Object.keys(query)) { + if (legal.has(key)) continue; + (stray ??= []).push(key); + } + if (!stray) return; + throw new Error( + `find('${object}') was given ${stray.length > 1 ? 'keys' : 'a key'} ` + + `${stray.map((k) => `'${k}'`).join(', ')} the query envelope does not carry. ` + + `${ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION} ` + + `Envelope keys: ${[...legal].sort().join(', ')}.`, + ); +} + /** * Build the action-body `ctx.engine` — the slim CRUD surface handler suites * use. Every call carries {@link buildActionExecutionContext} so `ctx.engine` @@ -1505,9 +1590,34 @@ export function buildActionEngineFacade(_deps: ActionExecutionDeps, ql: any, ec? await ql.delete(object, { where: { id }, context }); } }, - async find(object: string, query: Record): Promise>> { - const where = query && Object.keys(query).length ? { where: query } : {}; - const rows = await ql.find(object, { ...where, context } as any); + // [#15124] The ENVELOPE goes through — this arm no longer builds one. + // + // It used to take the `where` half alone and wrap it + // (`{ where: query }`, with an empty bag passed through unwrapped), so + // the facade's parameter shape differed from the engine's for no reason + // a caller could see. The cost was silent: an author who wrote the + // engine's own envelope got `{ where: { where: … } }`, which matches no + // row and resolves to `[]` with no error. The director seat withdrew + // that parameter shape rather than reserving the field name `where` + // across every customer's data model to refuse it — one platform, one + // query shape. The spec member (`ActionEngineFacade.find`, + // `packages/spec/src/ui/action-params.zod.ts`) now declares + // `EngineQueryOptions` by identity, so the handler writes what the + // engine reads and this arm only adds the identity. + // + // `context` is spread LAST on purpose: the facade is trusted and + // context-less by design (#3914, ADR-0096), so the elevated context it + // built wins over any `context` a caller put in the envelope. The + // envelope admits the key because every engine option bag does; it is + // not an authorization the caller gets to choose. Pinned in + // `action-engine-facade-find-envelope.test.ts`. + async find(object: string, query?: Record): Promise>> { + // …and the withdrawn shape is refused HERE, before the engine, so + // the untyped channel gets the same answer the type gives + // (`assertActionEngineFindEnvelope` above says why the engine's own + // check cannot be the whole of it). + assertActionEngineFindEnvelope(object, query); + const rows = await ql.find(object, { ...(query ?? {}), context } as any); return Array.isArray(rows) ? rows : ((rows as any)?.value ?? []); }, }; diff --git a/packages/spec/api-surface-declarations/automation.txt b/packages/spec/api-surface-declarations/automation.txt index dcb0b9bfb53..31cf33027d7 100644 --- a/packages/spec/api-surface-declarations/automation.txt +++ b/packages/spec/api-surface-declarations/automation.txt @@ -254,17 +254,17 @@ declare const ApprovalNodeApproverSchema: z.ZodObject<{ user: "user"; role: "role"; queue: "queue"; + team: "team"; manager: "manager"; department: "department"; - team: "team"; org_membership_level: "org_membership_level"; }>; value: z.ZodOptional; resolveAs: z.ZodOptional>; group: z.ZodOptional; organization: z.ZodOptional; @@ -286,17 +286,17 @@ declare const ApprovalNodeConfigSchema: z.ZodObject<{ user: "user"; role: "role"; queue: "queue"; + team: "team"; manager: "manager"; department: "department"; - team: "team"; org_membership_level: "org_membership_level"; }>; value: z.ZodOptional; resolveAs: z.ZodOptional>; group: z.ZodOptional; organization: z.ZodOptional; @@ -324,17 +324,17 @@ declare const ApprovalNodeConfigSchema: z.ZodObject<{ user: "user"; role: "role"; queue: "queue"; + team: "team"; manager: "manager"; department: "department"; - team: "team"; org_membership_level: "org_membership_level"; }>; value: z.ZodOptional; resolveAs: z.ZodOptional>; group: z.ZodOptional; organization: z.ZodOptional; @@ -346,8 +346,8 @@ declare const ApprovalNodeConfigSchema: z.ZodObject<{ position: "position"; text: "text"; user: "user"; - department: "department"; team: "team"; + department: "department"; }>>; multiple: z.ZodOptional; required: z.ZodOptional; @@ -378,9 +378,9 @@ declare const ApproverType: z.ZodEnum<{ user: "user"; role: "role"; queue: "queue"; + team: "team"; manager: "manager"; department: "department"; - team: "team"; org_membership_level: "org_membership_level"; }>; @@ -723,8 +723,8 @@ declare const DecisionOutputDefSchema: z.ZodObject<{ position: "position"; text: "text"; user: "user"; - department: "department"; team: "team"; + department: "department"; }>>; multiple: z.ZodOptional; required: z.ZodOptional; diff --git a/packages/spec/api-surface-declarations/data.txt b/packages/spec/api-surface-declarations/data.txt index 7a7306c3312..045d3b05f65 100644 --- a/packages/spec/api-surface-declarations/data.txt +++ b/packages/spec/api-surface-declarations/data.txt @@ -442,8 +442,8 @@ declare const ApiOperationSchema: z.ZodEnum<{ create: "create"; restore: "restore"; purge: "purge"; - import: "import"; export: "export"; + import: "import"; }>; // ── ApiPrimitive (type) ── @@ -521,9 +521,9 @@ declare const BaseEngineOptionsSchema: z.ZodObject<{ principalKind: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; audience: z.ZodOptional>; }, z.core.$strip>>>; performedBy: z.ZodOptional>>; direction: z.ZodDefault>>; realtimeSync: z.ZodDefault>; @@ -659,8 +659,8 @@ declare const DataSyncConfigSchema: z.ZodObject<{ append_only: "append_only"; }>>>; direction: z.ZodDefault>>; realtimeSync: z.ZodDefault>; @@ -792,8 +792,8 @@ declare const DeclarativeConnectorEntrySchema: z.ZodObject<{ append_only: "append_only"; }>>>; direction: z.ZodDefault>>; realtimeSync: z.ZodDefault>; diff --git a/packages/spec/api-surface-declarations/kernel.txt b/packages/spec/api-surface-declarations/kernel.txt index 3eec7f91bcf..68c037bc09c 100644 --- a/packages/spec/api-surface-declarations/kernel.txt +++ b/packages/spec/api-surface-declarations/kernel.txt @@ -1414,9 +1414,9 @@ declare const ExecutionContextSchema: z.ZodObject<{ principalKind: z.ZodOptional>; audience: z.ZodOptional>; }, z.core.$strip>>; performedBy: z.ZodOptional; declare const PermissionActionSchema: z.ZodEnum<{ delete: "delete"; update: "update"; - admin: "admin"; - execute: "execute"; - create: "create"; read: "read"; - import: "import"; + create: "create"; export: "export"; + execute: "execute"; + admin: "admin"; + import: "import"; manage: "manage"; configure: "configure"; share: "share"; @@ -5462,12 +5462,12 @@ declare const PluginPermissionSchema: z.ZodObject<{ actions: z.ZodArray>; sortOrder: z.ZodOptional>>; page: z.ZodOptional>; limit: z.ZodOptional>; @@ -6043,12 +6043,12 @@ declare const PluginSecurityManifestSchema: z.ZodObject<{ actions: z.ZodArray; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -22484,8 +22484,8 @@ declare const ObjectStackDefinitionSchema: z.ZodObject<{ append_only: "append_only"; }>>>; direction: z.ZodDefault>>; realtimeSync: z.ZodDefault>; @@ -32983,7 +32983,7 @@ declare const ObjectStackSchema: z.ZodObject<{ reason: z.ZodString; docsUrl: z.ZodOptional; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -44361,8 +44361,8 @@ declare const ObjectStackSchema: z.ZodObject<{ append_only: "append_only"; }>>>; direction: z.ZodDefault>>; realtimeSync: z.ZodDefault>; diff --git a/packages/spec/api-surface-declarations/security.txt b/packages/spec/api-surface-declarations/security.txt index 65897f150af..1351e0c27a8 100644 --- a/packages/spec/api-surface-declarations/security.txt +++ b/packages/spec/api-surface-declarations/security.txt @@ -236,8 +236,8 @@ declare const EffectiveObjectPermissionSchema: z.ZodType create: "create"; restore: "restore"; purge: "purge"; - import: "import"; export: "export"; + import: "import"; }>>>; }; }; @@ -257,10 +257,10 @@ declare const ExplainDecisionSchema: z.ZodObject<{ update: "update"; read: "read"; create: "create"; + transfer: "transfer"; restore: "restore"; purge: "purge"; export: "export"; - transfer: "transfer"; }>; principal: z.ZodObject<{ userId: z.ZodNullable; @@ -269,9 +269,9 @@ declare const ExplainDecisionSchema: z.ZodObject<{ principalKind: z.ZodOptional>; onBehalfOf: z.ZodOptional; kernelTier: z.ZodOptional; @@ -365,15 +365,15 @@ declare const ExplainDecisionSchema: z.ZodObject<{ visible: z.ZodBoolean; decidedBy: z.ZodOptional>; }, z.core.$strip>>; records: z.ZodOptional>; }, z.core.$strip>>>; }, z.core.$strip>; @@ -404,15 +404,15 @@ type ExplainLayerParsed = z.infer; declare const ExplainLayerSchema: z.ZodObject<{ layer: z.ZodEnum<{ sharing: "sharing"; - fls: "fls"; - rls: "rls"; owd_baseline: "owd_baseline"; tenant_isolation: "tenant_isolation"; principal: "principal"; required_permissions: "required_permissions"; object_crud: "object_crud"; + fls: "fls"; depth: "depth"; vama_bypass: "vama_bypass"; + rls: "rls"; }>; kernelTier: z.ZodOptional; @@ -485,11 +485,11 @@ type ExplainMatchedRule = z.input; declare const ExplainMatchedRuleSchema: z.ZodObject<{ kind: z.ZodEnum<{ sharing_rule: "sharing_rule"; - ownership: "ownership"; - team: "team"; tenant_filter: "tenant_filter"; owd_baseline: "owd_baseline"; + ownership: "ownership"; record_share: "record_share"; + team: "team"; territory: "territory"; rls_policy: "rls_policy"; }>; @@ -517,10 +517,10 @@ declare const ExplainOperationSchema: z.ZodEnum<{ update: "update"; read: "read"; create: "create"; + transfer: "transfer"; restore: "restore"; purge: "purge"; export: "export"; - transfer: "transfer"; }>; // ── ExplainRecordAttribution (type) ── @@ -541,11 +541,11 @@ declare const ExplainRecordAttributionSchema: z.ZodObject<{ rules: z.ZodDefault; @@ -577,10 +577,10 @@ declare const ExplainRequestSchema: z.ZodObject<{ update: "update"; read: "read"; create: "create"; + transfer: "transfer"; restore: "restore"; purge: "purge"; export: "export"; - transfer: "transfer"; }>; recordId: z.ZodOptional; recordIds: z.ZodOptional>; @@ -1213,13 +1213,13 @@ declare const permissionForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { diff --git a/packages/spec/api-surface-declarations/system.txt b/packages/spec/api-surface-declarations/system.txt index 9d5cf3f8994..a3618198c65 100644 --- a/packages/spec/api-surface-declarations/system.txt +++ b/packages/spec/api-surface-declarations/system.txt @@ -2850,8 +2850,8 @@ declare const ChangeSetSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -3260,19 +3267,12 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -5210,7 +5210,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -5264,6 +5264,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -5405,14 +5409,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -6367,7 +6367,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -6421,6 +6421,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -6562,14 +6566,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -7525,7 +7525,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -7579,6 +7579,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -7720,14 +7724,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -8682,7 +8682,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -8736,6 +8736,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -8877,14 +8881,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -10058,8 +10058,8 @@ declare const ChangeSetSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -10468,19 +10475,12 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -11844,7 +11844,7 @@ declare const ChangeSetSchema: z.ZodObject<{ reason: z.ZodString; docsUrl: z.ZodOptional; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -13060,8 +13060,8 @@ declare const ChangeSetSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -13470,19 +13477,12 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -15420,7 +15420,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -15474,6 +15474,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -15615,14 +15619,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -16577,7 +16577,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -16631,6 +16631,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -16772,14 +16776,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -17735,7 +17735,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -17789,6 +17789,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -17930,14 +17934,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -18892,7 +18892,7 @@ declare const ChangeSetSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -18946,6 +18946,10 @@ declare const ChangeSetSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -19087,14 +19091,10 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -20268,8 +20268,8 @@ declare const ChangeSetSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -20678,19 +20685,12 @@ declare const ChangeSetSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -22054,7 +22054,7 @@ declare const ChangeSetSchema: z.ZodObject<{ reason: z.ZodString; docsUrl: z.ZodOptional; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -23233,8 +23233,8 @@ declare const CreateObjectOperation: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -23643,19 +23650,12 @@ declare const CreateObjectOperation: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -25593,7 +25593,7 @@ declare const CreateObjectOperation: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -25647,6 +25647,10 @@ declare const CreateObjectOperation: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -25788,14 +25792,10 @@ declare const CreateObjectOperation: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -26750,7 +26750,7 @@ declare const CreateObjectOperation: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -26804,6 +26804,10 @@ declare const CreateObjectOperation: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -26945,14 +26949,10 @@ declare const CreateObjectOperation: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -27908,7 +27908,7 @@ declare const CreateObjectOperation: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -27962,6 +27962,10 @@ declare const CreateObjectOperation: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -28103,14 +28107,10 @@ declare const CreateObjectOperation: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -29065,7 +29065,7 @@ declare const CreateObjectOperation: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -29119,6 +29119,10 @@ declare const CreateObjectOperation: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -29260,14 +29264,10 @@ declare const CreateObjectOperation: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -30441,8 +30441,8 @@ declare const CreateObjectOperation: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -30851,19 +30858,12 @@ declare const CreateObjectOperation: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -32227,7 +32227,7 @@ declare const CreateObjectOperation: z.ZodObject<{ reason: z.ZodString; docsUrl: z.ZodOptional; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -34190,8 +34190,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -34600,19 +34607,12 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -36550,7 +36550,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -36604,6 +36604,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -36745,14 +36749,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -37707,7 +37707,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -37761,6 +37761,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -37902,14 +37906,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -38865,7 +38865,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -38919,6 +38919,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -39060,14 +39064,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -40022,7 +40022,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -40076,6 +40076,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -40217,14 +40221,10 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -41398,8 +41398,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -41808,19 +41815,12 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -43184,7 +43184,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ reason: z.ZodString; docsUrl: z.ZodOptional; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -43821,8 +43821,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -44231,19 +44238,12 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -44538,9 +44538,9 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ description: z.ZodOptional; defaultTab: z.ZodOptional; tabPosition: z.ZodOptional>; allowSkip: z.ZodOptional; @@ -44552,9 +44552,9 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ splitSize: z.ZodOptional; splitResizable: z.ZodOptional; drawerSide: z.ZodOptional>; drawerWidth: z.ZodOptional; @@ -45062,8 +45062,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -45472,19 +45479,12 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -45779,9 +45779,9 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ description: z.ZodOptional; defaultTab: z.ZodOptional; tabPosition: z.ZodOptional>; allowSkip: z.ZodOptional; @@ -45793,9 +45793,9 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ splitSize: z.ZodOptional; splitResizable: z.ZodOptional; drawerSide: z.ZodOptional>; drawerWidth: z.ZodOptional; @@ -46489,8 +46489,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -46574,7 +46574,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -46648,7 +46648,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -46738,8 +46738,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; filterBy: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -47163,7 +47163,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -47237,7 +47237,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -47383,8 +47383,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -47468,7 +47468,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -47542,7 +47542,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -47689,8 +47689,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -47774,7 +47774,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -47848,7 +47848,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -47994,8 +47994,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -48079,7 +48079,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -48153,7 +48153,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -48300,8 +48300,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -48385,7 +48385,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -48459,7 +48459,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -48605,8 +48605,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -48690,7 +48690,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -48764,7 +48764,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -48911,8 +48911,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -48996,7 +48996,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49070,7 +49070,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49216,8 +49216,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -49301,7 +49301,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49375,7 +49375,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49522,8 +49522,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -49607,7 +49607,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49681,7 +49681,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49827,8 +49827,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -49912,7 +49912,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -49986,7 +49986,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -50133,8 +50133,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -50218,7 +50218,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -50292,7 +50292,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -50438,8 +50438,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -50523,7 +50523,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -50597,7 +50597,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -50744,8 +50744,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -50829,7 +50829,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -50903,7 +50903,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -51049,8 +51049,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -51134,7 +51134,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -51208,7 +51208,7 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }[] | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; limit?: number | undefined; } | undefined; @@ -51436,9 +51436,9 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ stepSize: z.ZodOptional; showGridLines: z.ZodDefault; position: z.ZodOptional>; logarithmic: z.ZodDefault; @@ -51464,9 +51464,9 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ stepSize: z.ZodOptional; showGridLines: z.ZodDefault; position: z.ZodOptional>; logarithmic: z.ZodDefault; @@ -51604,8 +51604,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ }>>; sortBy: z.ZodOptional; sortOrder: z.ZodOptional>; limit: z.ZodOptional; stageOrder: z.ZodOptional>>; @@ -51767,8 +51767,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ order: z.ZodOptional>; }, z.core.$strict>>>; drilldown: z.ZodDefault; @@ -53807,16 +53807,16 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ object: z.ZodString; active: z.ZodDefault; accessLevel: z.ZodDefault>; sharedWith: z.ZodObject<{ type: z.ZodEnum<{ user: "user"; field: "field"; position: "position"; - business_unit: "business_unit"; team: "team"; + business_unit: "business_unit"; unit_and_subordinates: "unit_and_subordinates"; }>; value: z.ZodString; @@ -54562,8 +54562,8 @@ declare const EnvironmentArtifactSchema: z.ZodObject<{ append_only: "append_only"; }>>>; direction: z.ZodDefault>>; realtimeSync: z.ZodDefault>; @@ -58482,8 +58482,8 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -58892,19 +58899,12 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -60842,7 +60842,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -60896,6 +60896,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -61037,14 +61041,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -61999,7 +61999,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -62053,6 +62053,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -62194,14 +62198,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -63157,7 +63157,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -63211,6 +63211,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -63352,14 +63356,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -64314,7 +64314,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ } | undefined; sort?: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; }[] | undefined; filter?: { field: string; @@ -64368,6 +64368,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ colorField?: string | undefined; allDayField?: string | undefined; } | undefined; + sharing?: { + type: "personal" | "collaborative"; + lockedBy?: string | undefined; + } | undefined; aria?: { ariaLabel?: string | (Record & { key?: never; @@ -64509,14 +64513,10 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ pageSizeOptions?: number[] | undefined; } | undefined; pageName?: undefined; - sharing?: { - type: "personal" | "collaborative"; - lockedBy?: string | undefined; - } | undefined; grouping?: { fields: { field: string; - order: "desc" | "asc"; + order: "asc" | "desc"; collapsed: boolean; }[]; } | undefined; @@ -65690,8 +65690,8 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strict>>>; filter: z.ZodOptional; allDayField: z.ZodOptional; }, z.core.$strict>>; + sharing: z.ZodOptional>; + lockedBy: z.ZodOptional; + }, z.core.$strict>>; aria: z.ZodOptional & { key?: never; @@ -66100,19 +66107,12 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ pageSizeOptions: z.ZodOptional>; }, z.core.$strict>>; pageName: z.ZodOptional; - sharing: z.ZodOptional>; - lockedBy: z.ZodOptional; - }, z.core.$strict>>; grouping: z.ZodOptional>; collapsed: z.ZodDefault; }, z.core.$strict>>; @@ -67476,7 +67476,7 @@ declare const MigrationOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ reason: z.ZodString; docsUrl: z.ZodOptional; }, z.core.$strict>>; - }, z.core.$strict>>>(config: T & Record, never> & (T extends { + }, z.core.$strict>>>(config: T & Record, never> & (T extends { actions: infer As extends readonly unknown[]; } ? { actions: { [I in keyof As]: As[I] extends infer T_1 ? T_1 extends As[I] ? T_1 extends { @@ -70592,8 +70592,8 @@ type SupplierAssessmentStatus = z.input; // ── SupplierAssessmentStatusSchema (const) ── declare const SupplierAssessmentStatusSchema: z.ZodEnum<{ - failed: "failed"; expired: "expired"; + failed: "failed"; completed: "completed"; pending: "pending"; in_progress: "in_progress"; @@ -70627,8 +70627,8 @@ declare const SupplierSecurityAssessmentSchema: z.ZodObject<{ high: "high"; }>; status: z.ZodEnum<{ - failed: "failed"; expired: "expired"; + failed: "failed"; completed: "completed"; pending: "pending"; in_progress: "in_progress"; @@ -72673,13 +72673,13 @@ declare const emailTemplateForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { diff --git a/packages/spec/api-surface-declarations/ui.txt b/packages/spec/api-surface-declarations/ui.txt index 2cfe91bb48e..e48b030fe5a 100644 --- a/packages/spec/api-surface-declarations/ui.txt +++ b/packages/spec/api-surface-declarations/ui.txt @@ -132,39 +132,90 @@ interface ActionEngineFacade { */ delete(object: string, idOrIds: string | string[]): Promise; /** - * Read the rows of `object` that match `filter`. + * Read the rows of `object` that `query` selects. * - * `filter` is a FILTER — the `where` HALF of an ObjectQL query, the same - * {@link FilterCondition} that `QueryAST.where` carries: implicit equality - * `{ field: value }`, explicit operators `{ field: { $in: [...] } }`, - * `$and` / `$or` / `$not`. It is NOT the query ENVELOPE - * (`{ where, fields, orderBy, limit }`) that `DataEngine.find` and ObjectQL's - * own `engine.find` take — the shape this parameter's former name, `query`, - * invited. The runtime builds the envelope itself: `buildActionEngineFacade`'s - * `find` arm (`packages/runtime/src/action-execution.ts`, `:1183` on - * `369da918`) wraps a non-empty filter as `{ where: filter }` and passes an - * EMPTY filter (`{}`) through unwrapped — the unfiltered read. + * ## One platform, one query shape * - * Two consequences, both silent (#14175): + * `query` is the ENGINE's query envelope — {@link EngineQueryOptions}, the + * very type `IDataEngine.find` and ObjectQL's own `engine.find` take, named + * here by identity rather than restated. The filter goes under `where`, and + * the rest of the envelope (`fields`, `orderBy`, `limit`, `offset`, + * `expand`, `search`, …) means exactly what it means on the engine: * - * - An envelope passed here becomes `{ where: { where: … } }`. No object has - * a field named `where`, so the read matches nothing and resolves to `[]` - * with no error. A handler that made this mistake ran to completion over - * zero rows for as long as it shipped, and its own hand-written test - * double — written to the same belief, reading `query.where` — passed - * every assertion. - * - Because `{}` skips the wrap, an unfiltered call works under EITHER - * reading, so a handler mixing one unfiltered read with envelope-shaped - * ones looks partially alive rather than uniformly dead. + * ```ts + * await ctx.engine.find('todo_task', { where: { status: 'completed' } }); + * await ctx.engine.find('todo_task', { where: { status: 'open' }, fields: ['id', 'subject'], limit: 50 }); + * await ctx.engine.find('todo_task', {}); // the unfiltered read + * ``` * - * What the type buys, exactly: `FilterCondition` refuses a primitive and a - * mistyped logical operator (`$and` / `$or` not arrays, `$not` not a - * filter). It does NOT refuse `{ where: … }` — its string index signature is - * what lets any field name stand as a key, and `where` is a string — so the - * envelope mistake still compiles, and this doc comment, not the type, is - * the contract of record. Both halves are pinned in `action-params.test.ts`. + * ## What changed, and why it is a WITHDRAWAL rather than a narrowing + * + * Until #15124 this slot took a bare `FilterCondition` — the `where` + * half alone — and the runtime's `find` arm wrapped it + * (`packages/runtime/src/action-execution.ts`, `:1183` on `369da918`; that + * wrap is gone as of this card). That parameter shape differed from the + * engine's for no reason a caller could see, and the cost was + * silent: an author who wrote the engine's own envelope got + * `{ where: { where: … } }`, which matches no row (no object has a field + * named `where`) and resolves to `[]` with no error. A handler that made + * that mistake ran to completion over zero rows for as long as it shipped, + * and its hand-written test double — written to the same belief — passed + * every assertion. `{}` skipped the wrap, so one unfiltered read kept + * working under either belief and a dead handler looked partially alive. + * + * #14175 declared the bare filter and pinned the gap; #15124 withdraws the + * shape instead. Closing the gap the other way would have had to assert a + * vocabulary fact the spec declares nowhere — that no object may carry a + * field named `where` — and that is a word taken from every customer's data + * model to buy one parameter's compile-time check. Aligning the parameter + * removes the ambiguity at its root: the most natural spelling is now the + * correct one, and nothing is reserved. + * + * Migration is lossless and mechanical: `find(o, f)` → `find(o, { where: f })` + * (ADR-0087 semantic migration `action-engine-facade-find-query-envelope`). + * An unfiltered `find(o, {})` is unchanged. + * + * ## What the type refuses, measured + * + * A bare filter no longer type-checks, on BOTH paths a TYPED caller can + * reach it by: an object literal (`{ status: 'completed' }`) fails the + * excess-property check, because a field name is not an envelope key; and a + * filter held in a variable typed `FilterCondition` fails TS2559 — + * `EngineQueryOptions` is a weak type, every key optional, and a filter of + * field names has no property in common with it. The envelope's own keys are + * typed, so `where: 'a = b'`, `fields: 'id,subject'` and `limit: '50'` are + * refused too. + * + * ## …and what refuses it for a caller the TYPE never reached + * + * `buildActionEngineFacade` returns `any`, so a handler in a JS config, one + * annotated with a local copy of this context, or a `(ctx: any)` handler is + * bound by nothing here. For those the runtime arm refuses the withdrawn + * shape itself, before the engine, carrying the same prescription + * (`ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION`, + * `packages/runtime/src/action-execution.ts`) and reading its key set off + * THIS schema so the two channels cannot drift apart. + * + * ⚠️ That arm is load-bearing rather than belt-and-braces, and the reason is + * a `null`: the engine's own unknown-option refusal (#4371) exempts a + * `null`-VALUED key, because on an option bag a `null` is a withdrawal. On a + * FILTER it is the "rows with no X" idiom — so `{ deleted_at: null }` passed + * straight through would be dropped unexecuted and the read would widen to + * EVERY row, silently, to a caller whose next line is often a delete. + * + * ## `context` is the caller's to pass and NOT the caller's to choose + * + * The envelope carries `context` because every engine option bag does. This + * facade is TRUSTED and context-less by design (#2849, ADR-0096): the + * runtime stamps its own elevated `ExecutionContext` last, so a + * caller-supplied `context` is overridden rather than honoured. Do not write + * one — it reads as authorization and is none. + * + * Every clause above is pinned in `action-params.test.ts`, and the + * pass-through is pinned against the runtime in + * `packages/runtime/src/action-engine-facade-find-envelope.test.ts`. */ - find(object: string, filter: FilterCondition): Promise>>; + find(object: string, query: EngineQueryOptions): Promise>>; } // ── ActionHandler (type) ── @@ -2878,8 +2929,8 @@ declare const ComponentPropsMap: { }>>; type: z.ZodOptional; position: z.ZodDefault>; alwaysShowStrip: z.ZodOptional; items: z.ZodArray; }, z.core.$strict>>]>>; limit: z.ZodDefault; @@ -3318,8 +3369,8 @@ declare const ComponentPropsMap: { email: "email"; system: "system"; note: "note"; - approval: "approval"; sharing: "sharing"; + approval: "approval"; event: "event"; comment: "comment"; field_change: "field_change"; @@ -3376,8 +3427,8 @@ declare const ComponentPropsMap: { email: "email"; system: "system"; note: "note"; - approval: "approval"; sharing: "sharing"; + approval: "approval"; event: "event"; comment: "comment"; field_change: "field_change"; @@ -3452,8 +3503,8 @@ declare const ComponentPropsMap: { email: "email"; system: "system"; note: "note"; - approval: "approval"; sharing: "sharing"; + approval: "approval"; event: "event"; comment: "comment"; field_change: "field_change"; @@ -4355,8 +4406,8 @@ declare const ComponentPropsMap: { sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -4538,8 +4589,8 @@ declare const ComponentPropsMap: { sort: z.ZodOptional; }, z.core.$strip>>>; defaultSort: z.ZodOptional; @@ -4807,8 +4858,8 @@ declare const ComponentPropsMap: { sort: z.ZodOptional; }, z.core.$strip>>>; data: z.ZodOptional>; @@ -4893,9 +4944,9 @@ declare const ComponentPropsMap: { }>>]>>; defaultTab: z.ZodOptional; tabPosition: z.ZodOptional>; allowSkip: z.ZodOptional; @@ -4907,9 +4958,9 @@ declare const ComponentPropsMap: { splitSize: z.ZodOptional; splitResizable: z.ZodOptional; drawerSide: z.ZodOptional>; drawerWidth: z.ZodOptional>; @@ -5128,8 +5179,8 @@ declare const ComponentPropsMap: { sort: z.ZodOptional; }, z.core.$strip>>>; map: z.ZodOptional; }, z.core.$strip>>>; gantt: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -7344,8 +7395,8 @@ declare const ElementRecordPickerPropsSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -10901,8 +10952,8 @@ declare const ObjectCalendarPropsSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; data: z.ZodOptional>; @@ -10992,9 +11043,9 @@ declare const ObjectFormPropsSchema: z.ZodObject<{ }>>]>>; defaultTab: z.ZodOptional; tabPosition: z.ZodOptional>; allowSkip: z.ZodOptional; @@ -11006,9 +11057,9 @@ declare const ObjectFormPropsSchema: z.ZodObject<{ splitSize: z.ZodOptional; splitResizable: z.ZodOptional; drawerSide: z.ZodOptional>; drawerWidth: z.ZodOptional>; @@ -11177,8 +11228,8 @@ declare const ObjectGanttPropsSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; gantt: z.ZodOptional; }, z.core.$strip>>>; defaultSort: z.ZodOptional; @@ -12301,8 +12352,8 @@ declare const ObjectMapPropsSchema: z.ZodObject<{ sort: z.ZodOptional; }, z.core.$strip>>>; map: z.ZodOptional; }, z.core.$strip>>>; limit: z.ZodOptional; @@ -18637,8 +18688,8 @@ declare const PageTabsProps: z.ZodObject<{ }>>; type: z.ZodOptional; position: z.ZodDefault>; alwaysShowStrip: z.ZodOptional; items: z.ZodArray; }, z.core.$strict>>]>>; limit: z.ZodDefault; @@ -27508,13 +27559,13 @@ declare const actionForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { @@ -27708,13 +27759,13 @@ declare const appForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { @@ -27965,13 +28016,13 @@ declare const dashboardForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { @@ -28165,13 +28216,13 @@ declare const datasetForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { @@ -28444,13 +28495,13 @@ declare const pageForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { @@ -28650,13 +28701,13 @@ declare const reportForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { @@ -28870,13 +28921,13 @@ declare const viewForm: { title?: string | undefined; description?: string | undefined; defaultTab?: string | undefined; - tabPosition?: "left" | "right" | "top" | "bottom" | undefined; + tabPosition?: "top" | "left" | "right" | "bottom" | undefined; allowSkip?: boolean | undefined; showStepIndicator?: boolean | undefined; splitDirection?: "vertical" | "horizontal" | undefined; splitSize?: number | undefined; splitResizable?: boolean | undefined; - drawerSide?: "left" | "right" | "top" | "bottom" | undefined; + drawerSide?: "top" | "left" | "right" | "bottom" | undefined; drawerWidth?: string | undefined; modalSize?: "default" | "full" | "sm" | "lg" | "xl" | undefined; data?: { diff --git a/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts b/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts new file mode 100644 index 00000000000..e3344f3b067 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.action-engine-facade-find-query-envelope.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The action facade's `find` took the `where` HALF of a query while every other +// `find` on the platform took the whole envelope. The rewrite is mechanical and +// lossless, but it lives in an action HANDLER's source — a TypeScript function +// body, not a keyed metadata document — so `objectstack migrate meta` cannot +// reach it and it is a semantic entry rather than a D2 conversion. +export const entry: SemanticMigration = { + id: 'action-engine-facade-find-query-envelope', + surface: 'Action handler body — `ctx.engine.find(object, filter)` ' + + '(`ActionEngineFacade.find`, `@objectstack/spec/ui`)', + replacement: '`ctx.engine.find(object, { where: filter })` — the engine\'s own query envelope ' + + '(`EngineQueryOptions`), the same options bag `IDataEngine.find` takes. The filter moves under ' + + '`where` verbatim: `find(\'task\', { status: \'open\' })` → ' + + '`find(\'task\', { where: { status: \'open\' } })`. An unfiltered `find(object, {})` is unchanged, ' + + 'and the rest of the envelope — `fields`, `orderBy`, `limit`, `offset`, `expand` — becomes ' + + 'reachable from a handler for the first time. A caller-supplied `context` is ignored: the ' + + 'facade is trusted and stamps its own elevated one.', + reason: + 'The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler ' + + 'is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` ' + + 'step can reach a call expression inside a function body. The change is a WITHDRAWAL of the ' + + 'parameter shape #14175 chose, ruled by the director seat (decision batch #123 item 3, ' + + '2026-09-12, 「同意」) on the long-term axis 「one platform, one query shape」. The facade had been ' + + 'given a shape different from the engine\'s — the `where` half alone — which made the most ' + + 'natural spelling the wrong one: an author who passed the engine\'s envelope got ' + + '`{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an ' + + 'unfiltered `{}` kept working under either belief so a dead handler looked partially alive. The ' + + 'alternative — refusing `where` at the top level with an intersection — was rejected because it ' + + 'asserts a vocabulary fact the spec declares nowhere, reserving the field name `where` across ' + + 'every customer\'s data model to buy one parameter\'s compile-time check.', + acceptanceCriteria: + 'Every `ctx.engine.find(...)` in the app\'s action handlers passes an envelope. Where the handler ' + + 'is annotated with the PUBLISHED `ActionHandlerContext`, `tsc --noEmit` finds every unmigrated ' + + 'call on its own — a bare filter is a compile error there, an object literal failing the ' + + 'excess-property check and a `FilterCondition` variable failing TS2559. ⚠️ Where it is NOT — a ' + + 'handler in an `objectstack.config.js` / `.mjs`, one annotated with a local copy of the context ' + + 'type, or a `(ctx: any)` handler — the type reaches nothing and a type-check alone proves ' + + 'nothing: those callers are refused at RUNTIME by the facade arm, with the same prescription, so ' + + 'the migration is complete for them only once each such handler has actually been RUN. Then ' + + 'confirm the reads that were already SILENTLY EMPTY: any handler that had been passing the ' + + 'envelope was resolving to `[]` on every call, so a suite written against the mistake passed and ' + + 'the row count is the only witness — re-run each migrated handler against seeded data and assert ' + + 'it now returns the rows its filter selects, rather than asserting it still resolves.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 625baecac6c..e5e14ba76f0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5590,6 +5590,49 @@ const step18: MigrationStep = { + 'dispatches matches the declaration (N for per-record, one for aggregate) — a mismatch that ' + 'used to be silent is what this key exists to surface.', }, + // The action facade's `find` took the `where` HALF of a query while every other + // `find` on the platform took the whole envelope. The rewrite is mechanical and + // lossless, but it lives in an action HANDLER's source — a TypeScript function + // body, not a keyed metadata document — so `objectstack migrate meta` cannot + // reach it and it is a semantic entry rather than a D2 conversion. + { + id: 'action-engine-facade-find-query-envelope', + surface: 'Action handler body — `ctx.engine.find(object, filter)` ' + + '(`ActionEngineFacade.find`, `@objectstack/spec/ui`)', + replacement: '`ctx.engine.find(object, { where: filter })` — the engine\'s own query envelope ' + + '(`EngineQueryOptions`), the same options bag `IDataEngine.find` takes. The filter moves under ' + + '`where` verbatim: `find(\'task\', { status: \'open\' })` → ' + + '`find(\'task\', { where: { status: \'open\' } })`. An unfiltered `find(object, {})` is unchanged, ' + + 'and the rest of the envelope — `fields`, `orderBy`, `limit`, `offset`, `expand` — becomes ' + + 'reachable from a handler for the first time. A caller-supplied `context` is ignored: the ' + + 'facade is trusted and stamps its own elevated one.', + reason: + 'The rewrite itself is lossless and mechanical, but it is not automatable here: an action handler ' + + 'is authored TypeScript, and the chain rewrites stored metadata by key, so no `os migrate meta` ' + + 'step can reach a call expression inside a function body. The change is a WITHDRAWAL of the ' + + 'parameter shape #14175 chose, ruled by the director seat (decision batch #123 item 3, ' + + '2026-09-12, 「同意」) on the long-term axis 「one platform, one query shape」. The facade had been ' + + 'given a shape different from the engine\'s — the `where` half alone — which made the most ' + + 'natural spelling the wrong one: an author who passed the engine\'s envelope got ' + + '`{ where: { where: … } }`, matching no row and resolving to `[]` with no error, while an ' + + 'unfiltered `{}` kept working under either belief so a dead handler looked partially alive. The ' + + 'alternative — refusing `where` at the top level with an intersection — was rejected because it ' + + 'asserts a vocabulary fact the spec declares nowhere, reserving the field name `where` across ' + + 'every customer\'s data model to buy one parameter\'s compile-time check.', + acceptanceCriteria: + 'Every `ctx.engine.find(...)` in the app\'s action handlers passes an envelope. Where the handler ' + + 'is annotated with the PUBLISHED `ActionHandlerContext`, `tsc --noEmit` finds every unmigrated ' + + 'call on its own — a bare filter is a compile error there, an object literal failing the ' + + 'excess-property check and a `FilterCondition` variable failing TS2559. ⚠️ Where it is NOT — a ' + + 'handler in an `objectstack.config.js` / `.mjs`, one annotated with a local copy of the context ' + + 'type, or a `(ctx: any)` handler — the type reaches nothing and a type-check alone proves ' + + 'nothing: those callers are refused at RUNTIME by the facade arm, with the same prescription, so ' + + 'the migration is complete for them only once each such handler has actually been RUN. Then ' + + 'confirm the reads that were already SILENTLY EMPTY: any handler that had been passing the ' + + 'envelope was resolving to `[]` on every call, so a suite written against the mistake passed and ' + + 'the row count is the only witness — re-run each migrated handler against seeded data and assert ' + + 'it now returns the rows its filter selects, rather than asserting it still resolves.', + }, { id: 'address-location-value-unknown-keys-refused', surface: 'stored `address` and `location` field VALUES (`AddressSchema` / `AddressValueSchema`, ' diff --git a/packages/spec/src/ui/action-params.test.ts b/packages/spec/src/ui/action-params.test.ts index 8181e4c704b..402b40ce9c3 100644 --- a/packages/spec/src/ui/action-params.test.ts +++ b/packages/spec/src/ui/action-params.test.ts @@ -10,6 +10,7 @@ import { type ResolvedActionParam, } from './action-params.zod'; import type { FilterCondition } from '../data/filter.zod'; +import type { EngineQueryOptions } from '../data/data-engine.zod'; import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; const codes = (issues: ReturnType) => issues.map((i) => i.code).sort(); @@ -396,76 +397,113 @@ describe('#5779 — ActionSession `positions` canonical + `roles` deprecated ali }); // --------------------------------------------------------------------------- -// #14175 — `ActionEngineFacade.find` takes a FILTER, never an ObjectQL envelope +// #15124 — `ActionEngineFacade.find` takes the engine's QUERY ENVELOPE +// (#14175's bare-filter parameter shape is withdrawn) // --------------------------------------------------------------------------- type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; type Assert< T extends true > = T; // The declared slot, read off the interface — not a retyped copy of it, so a -// re-widening back to an open record, or a rename of the type behind it, fails -// HERE rather than in the first consumer to notice. -type FindFilter = Parameters[1]; +// re-widening back to an open record, a re-narrowing back to the bare filter, +// or a rename of the type behind it, fails HERE rather than in the first +// consumer to notice. +type FindQuery = Parameters[1]; // The type-level pin (the tsc channel, `tsc -p tsconfig.test.json`). `Eq` is -// the strict mutual-assignability test, so `Record` — the type -// this slot carried before, and the one it must not drift back to — does not -// satisfy it (measured: the same `Assert` against `Record` is -// a TS2344). Exported, as the sibling pins are, so `noUnusedLocals` does not -// read a type that exists only to be checked as one that is never used. -export type FindFilterIsFilterCondition = Assert< Eq< FindFilter, FilterCondition > >; - -describe('#14175 — ActionEngineFacade.find takes a FILTER (the `where` half), never an ObjectQL envelope', () => { - it('types the second parameter as the published `FilterCondition` (the tsc channel)', () => { - // The value-level half of `FindFilterIsFilterCondition` above: a literal +// the strict mutual-assignability test, so neither `Record` +// nor `FilterCondition` — the type this slot carried between #14175 and +// #15124, and the one it must not drift back to — satisfies it. Reading the +// engine's published type BY IDENTITY is the whole point of the ruling ("one +// platform, one query shape"): a structural copy of the envelope would pass a +// weaker pin and then drift the moment the engine's own options grow a key. +// Exported, as the sibling pins are, so `noUnusedLocals` does not read a type +// that exists only to be checked as one that is never used. +export type FindQueryIsEngineQueryOptions = Assert< Eq< FindQuery, EngineQueryOptions > >; + +describe('#15124 — ActionEngineFacade.find takes the engine query envelope, never a bare filter', () => { + it('types the second parameter as the published `EngineQueryOptions` (the tsc channel)', () => { + // The value-level half of `FindQueryIsEngineQueryOptions` above: a literal // annotated with the slot type, so the runtime run exercises the same // declaration the type pin reads. - const filter: FindFilter = { position_code: 'qa_lead', active: true }; - expect(Object.keys(filter)).toEqual(['position_code', 'active']); - }); - - it('positive control — every shape a handler legitimately passes compiles, the empty filter included', () => { - const implicitEquality: FindFilter = { status: 'completed' }; - const explicitOperator: FindFilter = { position_code: { $in: ['qa_lead', 'qa_manager'] }, active: true }; - const logical: FindFilter = { - $and: [{ active: true }, { $or: [{ tier: 'a' }, { tier: 'b' }] }], - $not: { archived: true }, + const query: FindQuery = { where: { position_code: 'qa_lead', active: true } }; + expect(Object.keys(query)).toEqual(['where']); + }); + + it('positive control — the envelope spellings a handler passes compile, the unfiltered read included', () => { + const implicitEquality: FindQuery = { where: { status: 'completed' } }; + const explicitOperator: FindQuery = { where: { position_code: { $in: ['qa_lead', 'qa_manager'] }, active: true } }; + const logical: FindQuery = { + where: { + $and: [{ active: true }, { $or: [{ tier: 'a' }, { tier: 'b' }] }], + $not: { archived: true }, + }, }; - // The runtime passes THIS one through unwrapped — the unfiltered read, and - // the one call that kept working in the reporting app under either belief. - const unfiltered: FindFilter = {}; - - expect([implicitEquality, explicitOperator, logical, unfiltered].every((f) => typeof f === 'object')).toBe(true); - }); - - it('refuses at compile time what `FilterCondition` refuses — a primitive and a mistyped logical operator', () => { - // Each `@ts-expect-error` is itself checked: if the type ever ADMITS one of - // these, the directive goes unused and `tsc -p tsconfig.test.json` reds. - // @ts-expect-error — a filter is an object; a bare string is not a `where` half. - const primitive: FindFilter = 'position_code = qa_lead'; - // @ts-expect-error — `$and` is `FilterCondition[]`; a string is refused. - const andNotArray: FindFilter = { $and: 'active' }; - // @ts-expect-error — `$or` is `FilterCondition[]`; a bare object is refused. - const orNotArray: FindFilter = { $or: { active: true } }; - // @ts-expect-error — `$not` is a `FilterCondition`; a string is refused. - const notNotFilter: FindFilter = { $not: 'archived' }; - - expect([primitive, andNotArray, orNotArray, notNotFilter]).toHaveLength(4); - }); - - it('MEASURED GAP — the exact envelope mistake still compiles; the doc comment, not the type, is the contract', () => { - // `FilterCondition`'s string index signature is what lets a field NAME be a - // key, and `where` is a string — so the shape that returned `[]` in silence - // in the reporting app (`{ where: { position_code: 'qa_lead' } }`) is - // admitted by the type, one level down too. This pin RECORDS that - // measurement rather than hiding it: a later narrowing that refuses `where` - // at the top level turns it red on purpose, so the member's "does NOT - // refuse `{ where: … }`" sentence is updated with the type instead of - // drifting from it. - const envelope: FindFilter = { where: { position_code: 'qa_lead' } }; - const nested: FindFilter = { where: { where: { position_code: 'qa_lead' } } }; - - expect('where' in envelope && 'where' in nested).toBe(true); + // The rest of the envelope is reachable from a handler for the first time: + // under the bare-filter shape a handler could not project, sort or page at + // all, because the parameter had nowhere to carry those keys. + const projected: FindQuery = { where: { status: 'completed' }, fields: ['id', 'subject'], limit: 50 }; + const sorted: FindQuery = { orderBy: [{ field: 'due_date', order: 'asc' }], offset: 20 }; + // The unfiltered read — `{}` was the one call that worked under EITHER + // reading before this card, and it still means "every row". + const unfiltered: FindQuery = {}; + + expect([implicitEquality, explicitOperator, logical, projected, sorted, unfiltered] + .every((q) => typeof q === 'object')).toBe(true); + }); + + it('REFUSAL PIN — the bare filter #14175 declared no longer type-checks (the trap is inverted, not narrowed)', () => { + // This is the pin #14175 recorded as a MEASURED GAP, flipped. The envelope + // that returned `[]` in silence in the reporting app is now the RIGHT + // spelling (the positive controls above), and the bare filter that used to + // be right is the one tsc refuses. Each `@ts-expect-error` is itself + // checked: if the slot ever re-admits one of these, the directive goes + // unused and `tsc -p tsconfig.test.json` reds. + // + // The refusal is the object-literal excess-property check, which is what + // makes it LOUD at the call site an author actually writes: a field name is + // not an envelope key, so `{ status: … }` has nowhere to land. + // @ts-expect-error — `status` is a field name, not an envelope key; write `{ where: { status } }`. + const bareImplicitEquality: FindQuery = { status: 'completed' }; + // @ts-expect-error — the same for an explicit operator: it belongs under `where`. + const bareExplicitOperator: FindQuery = { position_code: { $in: ['qa_lead'] } }; + // @ts-expect-error — `$and` is a FILTER operator; at envelope level it is an unknown key. + const bareLogical: FindQuery = { $and: [{ active: true }] }; + // @ts-expect-error — an envelope is an object; a bare string is not one. + const primitive: FindQuery = 'position_code = qa_lead'; + + expect([bareImplicitEquality, bareExplicitOperator, bareLogical, primitive]).toHaveLength(4); + }); + + it('refuses a mistyped envelope key — the keys are the engine\'s, and they are typed', () => { + // @ts-expect-error — `where` is a `FilterCondition`; a bare string is refused. + const whereNotFilter: FindQuery = { where: 'status = completed' }; + // @ts-expect-error — `fields` is an array of field nodes; a comma string is refused. + const fieldsNotArray: FindQuery = { fields: 'id,subject' }; + // @ts-expect-error — `limit` is a number. + const limitNotNumber: FindQuery = { limit: '50' }; + + expect([whereNotFilter, fieldsNotArray, limitNotNumber]).toHaveLength(3); + }); + + it('the refusal survives the VARIABLE path too — not just the object-literal check', () => { + // The obvious worry about narrowing an all-optional target is that only + // FRESH object literals get the excess-property check, so a filter reaching + // the call through a variable would slide in structurally and fail at + // runtime instead. MEASURED: it does not. `EngineQueryOptions` is a weak + // type (every key optional), and a `FilterCondition` holding field names + // has no property in common with it, so tsc answers TS2559 — "has no + // properties in common with" — on the assignment itself. `FilterCondition`'s + // string index signature does not rescue it. + // + // Pinned because it is the half a reader assumes is open: the member doc + // says the old spelling fails at COMPILE time, and this is the leg of that + // claim the literal pin above does not cover. + const held: FilterCondition = { status: 'completed' }; + // @ts-expect-error — TS2559: a bare filter has no property in common with the envelope. + const viaVariable: FindQuery = held; + + expect(Object.keys(viaVariable)).toEqual(['status']); }); }); diff --git a/packages/spec/src/ui/action-params.zod.ts b/packages/spec/src/ui/action-params.zod.ts index fccd803f357..356a7decf17 100644 --- a/packages/spec/src/ui/action-params.zod.ts +++ b/packages/spec/src/ui/action-params.zod.ts @@ -29,7 +29,7 @@ import { z } from 'zod'; import { valueSchemaFor } from '../data/field-value.zod'; -import type { FilterCondition } from '../data/filter.zod'; +import type { EngineQueryOptions } from '../data/data-engine.zod'; import type { FieldErrorCode } from '../api/errors.zod'; import { lazySchema } from '../shared/lazy-schema'; @@ -232,11 +232,12 @@ export function validateActionParams( * at invoke time (`ai.exposed` + the ADR-0066 D4 capability gate), not here. * * Two members carry an argument contract the signature alone does not settle, - * and both state it on the member: `find` takes a bare FILTER — the `where` - * half of a query — and never an ObjectQL query envelope (#14175); `delete` - * accepts a single id OR an array of them, both as declared contract, served - * one row at a time (#15117). Read those doc comments before writing a handler - * or a test double against either. + * and both state it on the member: `find` takes the engine's own query + * ENVELOPE — {@link EngineQueryOptions}, by identity, the same type + * `IDataEngine.find` takes — and the bare-filter parameter shape #14175 chose + * is withdrawn (#15124); `delete` accepts a single id OR an array of them, + * both as declared contract, served one row at a time (#15117). Read those doc + * comments before writing a handler or a test double against either. */ export interface ActionEngineFacade { insert(object: string, data: Record): Promise<{ id: string }>; @@ -275,39 +276,90 @@ export interface ActionEngineFacade { */ delete(object: string, idOrIds: string | string[]): Promise; /** - * Read the rows of `object` that match `filter`. + * Read the rows of `object` that `query` selects. * - * `filter` is a FILTER — the `where` HALF of an ObjectQL query, the same - * {@link FilterCondition} that `QueryAST.where` carries: implicit equality - * `{ field: value }`, explicit operators `{ field: { $in: [...] } }`, - * `$and` / `$or` / `$not`. It is NOT the query ENVELOPE - * (`{ where, fields, orderBy, limit }`) that `DataEngine.find` and ObjectQL's - * own `engine.find` take — the shape this parameter's former name, `query`, - * invited. The runtime builds the envelope itself: `buildActionEngineFacade`'s - * `find` arm (`packages/runtime/src/action-execution.ts`, `:1183` on - * `369da918`) wraps a non-empty filter as `{ where: filter }` and passes an - * EMPTY filter (`{}`) through unwrapped — the unfiltered read. + * ## One platform, one query shape * - * Two consequences, both silent (#14175): + * `query` is the ENGINE's query envelope — {@link EngineQueryOptions}, the + * very type `IDataEngine.find` and ObjectQL's own `engine.find` take, named + * here by identity rather than restated. The filter goes under `where`, and + * the rest of the envelope (`fields`, `orderBy`, `limit`, `offset`, + * `expand`, `search`, …) means exactly what it means on the engine: * - * - An envelope passed here becomes `{ where: { where: … } }`. No object has - * a field named `where`, so the read matches nothing and resolves to `[]` - * with no error. A handler that made this mistake ran to completion over - * zero rows for as long as it shipped, and its own hand-written test - * double — written to the same belief, reading `query.where` — passed - * every assertion. - * - Because `{}` skips the wrap, an unfiltered call works under EITHER - * reading, so a handler mixing one unfiltered read with envelope-shaped - * ones looks partially alive rather than uniformly dead. + * ```ts + * await ctx.engine.find('todo_task', { where: { status: 'completed' } }); + * await ctx.engine.find('todo_task', { where: { status: 'open' }, fields: ['id', 'subject'], limit: 50 }); + * await ctx.engine.find('todo_task', {}); // the unfiltered read + * ``` * - * What the type buys, exactly: `FilterCondition` refuses a primitive and a - * mistyped logical operator (`$and` / `$or` not arrays, `$not` not a - * filter). It does NOT refuse `{ where: … }` — its string index signature is - * what lets any field name stand as a key, and `where` is a string — so the - * envelope mistake still compiles, and this doc comment, not the type, is - * the contract of record. Both halves are pinned in `action-params.test.ts`. + * ## What changed, and why it is a WITHDRAWAL rather than a narrowing + * + * Until #15124 this slot took a bare `FilterCondition` — the `where` + * half alone — and the runtime's `find` arm wrapped it + * (`packages/runtime/src/action-execution.ts`, `:1183` on `369da918`; that + * wrap is gone as of this card). That parameter shape differed from the + * engine's for no reason a caller could see, and the cost was + * silent: an author who wrote the engine's own envelope got + * `{ where: { where: … } }`, which matches no row (no object has a field + * named `where`) and resolves to `[]` with no error. A handler that made + * that mistake ran to completion over zero rows for as long as it shipped, + * and its hand-written test double — written to the same belief — passed + * every assertion. `{}` skipped the wrap, so one unfiltered read kept + * working under either belief and a dead handler looked partially alive. + * + * #14175 declared the bare filter and pinned the gap; #15124 withdraws the + * shape instead. Closing the gap the other way would have had to assert a + * vocabulary fact the spec declares nowhere — that no object may carry a + * field named `where` — and that is a word taken from every customer's data + * model to buy one parameter's compile-time check. Aligning the parameter + * removes the ambiguity at its root: the most natural spelling is now the + * correct one, and nothing is reserved. + * + * Migration is lossless and mechanical: `find(o, f)` → `find(o, { where: f })` + * (ADR-0087 semantic migration `action-engine-facade-find-query-envelope`). + * An unfiltered `find(o, {})` is unchanged. + * + * ## What the type refuses, measured + * + * A bare filter no longer type-checks, on BOTH paths a TYPED caller can + * reach it by: an object literal (`{ status: 'completed' }`) fails the + * excess-property check, because a field name is not an envelope key; and a + * filter held in a variable typed `FilterCondition` fails TS2559 — + * `EngineQueryOptions` is a weak type, every key optional, and a filter of + * field names has no property in common with it. The envelope's own keys are + * typed, so `where: 'a = b'`, `fields: 'id,subject'` and `limit: '50'` are + * refused too. + * + * ## …and what refuses it for a caller the TYPE never reached + * + * `buildActionEngineFacade` returns `any`, so a handler in a JS config, one + * annotated with a local copy of this context, or a `(ctx: any)` handler is + * bound by nothing here. For those the runtime arm refuses the withdrawn + * shape itself, before the engine, carrying the same prescription + * (`ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION`, + * `packages/runtime/src/action-execution.ts`) and reading its key set off + * THIS schema so the two channels cannot drift apart. + * + * ⚠️ That arm is load-bearing rather than belt-and-braces, and the reason is + * a `null`: the engine's own unknown-option refusal (#4371) exempts a + * `null`-VALUED key, because on an option bag a `null` is a withdrawal. On a + * FILTER it is the "rows with no X" idiom — so `{ deleted_at: null }` passed + * straight through would be dropped unexecuted and the read would widen to + * EVERY row, silently, to a caller whose next line is often a delete. + * + * ## `context` is the caller's to pass and NOT the caller's to choose + * + * The envelope carries `context` because every engine option bag does. This + * facade is TRUSTED and context-less by design (#2849, ADR-0096): the + * runtime stamps its own elevated `ExecutionContext` last, so a + * caller-supplied `context` is overridden rather than honoured. Do not write + * one — it reads as authorization and is none. + * + * Every clause above is pinned in `action-params.test.ts`, and the + * pass-through is pinned against the runtime in + * `packages/runtime/src/action-engine-facade-find-envelope.test.ts`. */ - find(object: string, filter: FilterCondition): Promise>>; + find(object: string, query: EngineQueryOptions): Promise>>; } /** diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index dc0147a4bcb..bde3fbc37b1 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3446,6 +3446,11 @@ "verb": "delete", "pinned": 1 }, + { + "file": "packages/runtime/src/action-engine-facade-find-envelope.test.ts", + "verb": "delete", + "pinned": 1 + }, { "file": "packages/runtime/src/action-engine-facade-nullish-id.test.ts", "verb": "delete", diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index bc75258863f..6601f4589de 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -21,6 +21,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/api/errors.zod.ts` — Standardized Error Codes Protocol - `node_modules/@objectstack/spec/src/automation/flow-function.zod.ts` — The contract for a **named handler function a `script` node invokes** — +- `node_modules/@objectstack/spec/src/data/data-engine.zod.ts` — Data Engine Protocol - `node_modules/@objectstack/spec/src/data/driver-sql.zod.ts` — Exports: SQLDialectSchema, DataTypeMappingSchema, SSLConfigSchema, SQLDriverConfigSchema, SQLiteDataTypeMappingDefaults - `node_modules/@objectstack/spec/src/data/driver.zod.ts` — Exports: DriverOptionsSchema, DriverCapabilitiesSchema, DriverInterfaceSchema, PoolConfigSchema, DriverConfigSchema - `node_modules/@objectstack/spec/src/data/driver/common.zod.ts` — Shared building blocks for the per-driver `datasource.config` shapes. @@ -35,7 +36,9 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Exports: HookBodyCapability, ExpressionBodySchema, ScriptBodySchema, HookBodySchema - `node_modules/@objectstack/spec/src/data/query.zod.ts` — QueryAST — Abstract Syntax Tree for data queries. +- `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index e91ebf9be5e..9be8ae689a8 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -23,6 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/api/errors.zod.ts` — Standardized Error Codes Protocol +- `node_modules/@objectstack/spec/src/data/data-engine.zod.ts` — Data Engine Protocol - `node_modules/@objectstack/spec/src/data/date-macros.zod.ts` — Date Macro Tokens — the declarative placeholders the UI substitutes - `node_modules/@objectstack/spec/src/data/feed.zod.ts` — Activity-timeline UI config enums, and the `sys_activity.type` built-in set. - `node_modules/@objectstack/spec/src/data/field-value.zod.ts` — Field runtime VALUE-shape contract (ADR-0104 D1). @@ -30,7 +31,9 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Exports: HookBodyCapability, ExpressionBodySchema, ScriptBodySchema, HookBodySchema - `node_modules/@objectstack/spec/src/data/query.zod.ts` — QueryAST — Abstract Syntax Tree for data queries. +- `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) +- `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas