diff --git a/.changeset/18163-export-hook-api-types.md b/.changeset/18163-export-hook-api-types.md new file mode 100644 index 00000000000..8fd064cd754 --- /dev/null +++ b/.changeset/18163-export-hook-api-types.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +--- + +`@objectstack/spec/data` now exports the typed hook `ctx.api` face — `HookApi`, `HookObjectApi`, `HookQuery`, `HookCountQuery`, `HookUpdateDoc`, `HookUpdateOptions`, `HookDeleteOptions`, `HookDoc` and `HookDriverPassthroughOptions` — so a metadata app's `*.hook.ts` imports the platform's type instead of hand-declaring one (#18163). The same entry additionally re-exports `EngineTransactionInfo` and `EngineTransactionOptions`, which its public declarations reference structurally: without them a consumer that imports only `@objectstack/spec/data` and emits declarations answers `TS2883: The inferred type ... cannot be named without a reference to ...`. Type-only re-exports of the declarations `@objectstack/spec/contracts` already publishes, not second declarations. + +```ts +import type { HookApi } from '@objectstack/spec/data'; + +const api = ctx.api as HookApi | undefined; +if (!api) return; +const owner = await api.object('user').findOne({ where: { id: ctx.input.owner } }); +``` + +The platform already implemented this surface; it just never published a type an app could import, so every app re-derived the engine's option vocabulary in a copy that drifts the moment the engine moves. The reference third-party app carried ~2,358 authored tokens of one in a single file, imported by 17 hook files. + +- **The query shape is `where`-only — there is no `filter` key, deliberately.** `RPC_QUERY_ALIAS_SLOTS` declares `filter` as the alias of `where` (and `top` as the alias of `limit`); every engine entry point folds the `where` slot, collapsing redundant identical spellings and REFUSING the slot when the two spellings carry different values. So `{ where, filter }` is silent when they happen to agree and a runtime throw when they do not. Omitting the alias keys makes it neither: `TS2353: 'filter' does not exist in type 'HookQuery'`, at the authoring site. +- **Not a second dialect of `IScopedContext`.** `contracts/scoped-context.ts` stays the CHECKED IMPLEMENTATION contract ObjectQL's `ScopedContext` and `ObjectRepository` carry `implements` clauses against, with its deliberately loose `Record` bags. This is the authoring half of the same seam: `HookApi` is assignable to `IScopedContext`, so `ctx.api as HookApi` stays a direct cast, and nothing about the older contract changes. +- **Every option shape is DERIVED, not transcribed.** Each is an `Omit`/`Pick` over the `Engine*Options` schemas that the engine's own per-method legal-key sets are pinned against, so a key added to a schema reaches the published type in the same run it reaches the engine's accepted set. `count` is the one shape without the driver pass-through keys, because the engine forwards no bag on that method and rejects them there — engine behaviour no document states, and exactly what a hand-written copy gets wrong. +- **What is deliberately absent, each for a stated reason**: `context` (the repository injects it and discards a caller's), the `cursor` / `distinct` / `upsert` tombstones, `sudo()` (the #5945 exclusion stands — `Hook.runAs: 'system'` is the declared way to run elevated), and `aggregate` / `execute` / `create` / `deleteById`. + +Additive only: eleven new exported names from `./data` (nine new declarations plus two type-only re-exports), no removal and no signature change, so nothing an existing consumer imports moves. + +Clause-②: yes (widening) diff --git a/packages/spec/api-surface-declarations/data.txt b/packages/spec/api-surface-declarations/data.txt index 2b387010e92..3ac7d745a6f 100644 --- a/packages/spec/api-surface-declarations/data.txt +++ b/packages/spec/api-surface-declarations/data.txt @@ -12,8 +12,8 @@ # excluded: documentation drift is `check:docs`'s axis, not this one. # # entry: ./data -# exported names: 833 -# declarations: 846 +# exported names: 844 +# declarations: 857 # # GENERATED — ⛔ never hand-edited. Regenerate after a real build: # pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec gen:api-surface-declarations @@ -6167,6 +6167,46 @@ declare const EngineQueryOptionsSchema: z.ZodObject<{ distinct: z.ZodOptional; }, z.core.$strip>; +// ── EngineTransactionInfo (interface) ── +interface EngineTransactionInfo { + /** + * `true` when THIS call opened the transaction and therefore owns its + * commit/rollback; `false` when it JOINED an already-open ambient one + * (ADR-0067 D2) and some outer caller owns the outcome. + * + * The join is correct and stays — a nested `begin` would take a second + * connection (deadlocking a single-connection SQLite pool) and would not be + * covered by the outer rollback. What was missing is that the callback + * could not TELL: a joined callback's `throw` unwinds work the outer owner + * may still commit or roll back on its own terms, and guarantees phrased + * as "this whole unit rolls back together" (`batchData`'s rollback + * response, ADR-0119 D4) hold only for the owner. A callback that must not + * promise what it does not control reads `owned` and says so. + */ + owned: boolean; +} + +// ── EngineTransactionOptions (interface) ── +interface EngineTransactionOptions { + /** + * Fail CLOSED when the datasource cannot give a real transaction. + * + * Default (`undefined` / `false`) keeps ADR-0119 D1's declared degrade: a + * driver without `beginTransaction` runs the callback with no transaction + * and no rollback, warning once. That degrade is right for callers who can + * live without atomicity (test doubles, in-memory drivers) and wrong for + * callers whose whole reason to open a transaction is the rollback. + * + * With `require: true` the engine THROWS instead of degrading, before the + * callback runs — so a caller that cannot tolerate losing atomicity states + * it once, at the call site, instead of re-deriving `batchData`'s probe. + * That probe is the precedent being generalized here (ADR-0119 D4, cited in + * older text as ADR-0118 D4 — see that ADR's renumbering note): an `atomic` + * request refuses rather than silently running best-effort. + */ + require?: boolean; +} + // ── EngineUpdateOptions (type) ── type EngineUpdateOptions = z.input; @@ -14276,6 +14316,26 @@ declare const GroupByNodeSchema: z.ZodUnion; +// ── HookApi (interface) ── +interface HookApi { + /** The repository for `name`, bound to this context. */ + object(name: string): HookObjectApi; + /** + * Run `callback` inside one driver transaction: committed when it returns, + * rolled back when it throws. + * + * The callback receives a NEW `HookApi` whose operations share the + * transaction handle — reach objects through THAT context (`tx.object(…)`), + * not the outer one, or the writes land outside the transaction. + * + * The second parameter is declared because the PRODUCER hands it + * unconditionally (`ScopedContext.transaction`, #5696). Contravariance keeps + * the zero- and one-argument callbacks authors actually write assignable, so + * the truthful signature is also the more permissive one. + */ + transaction(callback: (trxCtx: HookApi, info: EngineTransactionInfo) => Promise, opts?: EngineTransactionOptions): Promise; +} + // ── HookBody (type) ── type HookBody = z.input; @@ -14370,9 +14430,21 @@ declare const HookContextSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>; +// ── HookCountQuery (type) ── +type HookCountQuery = Omit; + +// ── HookDeleteOptions (type) ── +type HookDeleteOptions = Omit & HookDriverPassthroughOptions; + // ── HookDispatch (type) ── type HookDispatch = NonNullable; +// ── HookDoc (type) ── +type HookDoc = Record; + +// ── HookDriverPassthroughOptions (type) ── +type HookDriverPassthroughOptions = Pick; + // ── HookEvent (const) ── declare const HookEvent: z.ZodEnum<{ beforeInsert: "beforeInsert"; @@ -14388,9 +14460,51 @@ declare const HookEvent: z.ZodEnum<{ // ── HookEventType (type) ── type HookEventType = z.input; +// ── HookObjectApi (interface) ── +interface HookObjectApi { + /** + * Read every record the query selects. + * + * `Promise` mirrors `IScopedObjectRepository.find` and + * `IDataEngine.find`; narrowing it here would make this face disagree with + * the two declarations it forwards through. + */ + find(query?: HookQuery): Promise; + /** Read the ONE record the query selects, or `null`. */ + findOne(query?: HookQuery): Promise | null>; + /** Count the records the query selects. */ + count(query?: HookCountQuery): Promise; + /** Insert one record, or an array of records. */ + insert(data: HookDoc | HookDoc[]): Promise; + /** + * Update records: the record for the single-record form, the affected-row + * count for the predicate form (`{ where, multi: true }`), `null` when the + * write matched nothing. + */ + update(data: HookUpdateDoc, options?: HookUpdateOptions): Promise | number | null>; + /** + * Update a single record by id — the id travels as the first argument. + * + * Answers the written record, or `null` when the id matched nothing. A falsy + * id is not a narrower answer but a REFUSAL: `0` and `''` identify no row, so + * the dispatch rejects and the call throws. + */ + updateById(id: string | number, data: HookUpdateDoc): Promise | null>; + /** + * Delete records — `{ where, multi: true }` for the predicate form. + * + * `Promise` is what `IDataEngine.delete` declares, one door + * down from this method. + */ + delete(options?: HookDeleteOptions): Promise; +} + // ── HookParsed (type) ── type HookParsed = z.infer; +// ── HookQuery (type) ── +type HookQuery = Omit & HookDriverPassthroughOptions; + // ── HookSchema (const) ── declare const HookSchema: z.ZodObject<{ _lock: z.ZodOptional>; }, z.core.$strict>; +// ── HookUpdateDoc (type) ── +type HookUpdateDoc = HookDoc; + +// ── HookUpdateOptions (type) ── +type HookUpdateOptions = Omit & WriteObservabilityOptions & HookDriverPassthroughOptions; + // ── IMPORT_BOOLEAN_FALSE_TOKENS (const) ── declare const IMPORT_BOOLEAN_FALSE_TOKENS: ReadonlySet; diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 7b377f8eb60..83b8cb5cb81 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -238,6 +238,8 @@ "EngineQueryOptions (type)", "EngineQueryOptionsParsed (type)", "EngineQueryOptionsSchema (const)", + "EngineTransactionInfo (interface)", + "EngineTransactionOptions (interface)", "EngineUpdateOptions (type)", "EngineUpdateOptionsSchema (const)", "EqualityOperatorSchema (const)", @@ -337,6 +339,7 @@ "GroupByNode (type)", "GroupByNodeSchema (const)", "Hook (type)", + "HookApi (interface)", "HookBody (type)", "HookBodyCapability (const)", "HookBodyCapability (type)", @@ -344,11 +347,19 @@ "HookBodySchema (const)", "HookContext (type)", "HookContextSchema (const)", + "HookCountQuery (type)", + "HookDeleteOptions (type)", "HookDispatch (type)", + "HookDoc (type)", + "HookDriverPassthroughOptions (type)", "HookEvent (const)", "HookEventType (type)", + "HookObjectApi (interface)", "HookParsed (type)", + "HookQuery (type)", "HookSchema (const)", + "HookUpdateDoc (type)", + "HookUpdateOptions (type)", "IMPORT_BOOLEAN_FALSE_TOKENS (const)", "IMPORT_BOOLEAN_TRUE_TOKENS (const)", "IMPORT_REFERENCE_TYPES (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index f314c4048c6..2d2d8fe072a 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -233,6 +233,8 @@ "EngineQueryOptions": "src/data/data-engine.zod.ts#EngineQueryOptions (type)", "EngineQueryOptionsParsed": "src/data/data-engine.zod.ts#EngineQueryOptionsParsed (type)", "EngineQueryOptionsSchema": "src/data/data-engine.zod.ts#EngineQueryOptionsSchema (const)", + "EngineTransactionInfo": "src/contracts/objectql-engine.ts#EngineTransactionInfo (interface)", + "EngineTransactionOptions": "src/contracts/objectql-engine.ts#EngineTransactionOptions (interface)", "EngineUpdateOptions": "src/data/data-engine.zod.ts#EngineUpdateOptions (type)", "EngineUpdateOptionsSchema": "src/data/data-engine.zod.ts#EngineUpdateOptionsSchema (const)", "EqualityOperatorSchema": "src/data/filter.zod.ts#EqualityOperatorSchema (const)", @@ -328,17 +330,26 @@ "GroupByNode": "src/data/query.zod.ts#GroupByNode (type)", "GroupByNodeSchema": "src/data/query.zod.ts#GroupByNodeSchema (const)", "Hook": "src/data/hook.zod.ts#Hook (type)", + "HookApi": "src/data/hook-api.ts#HookApi (interface)", "HookBody": "src/data/hook-body.zod.ts#HookBody (type)", "HookBodyCapability": "src/data/hook-body.zod.ts#HookBodyCapability (type)", "HookBodyParsed": "src/data/hook-body.zod.ts#HookBodyParsed (type)", "HookBodySchema": "src/data/hook-body.zod.ts#HookBodySchema (const)", "HookContext": "src/data/hook.zod.ts#HookContext (type)", "HookContextSchema": "src/data/hook.zod.ts#HookContextSchema (const)", + "HookCountQuery": "src/data/hook-api.ts#HookCountQuery (type)", + "HookDeleteOptions": "src/data/hook-api.ts#HookDeleteOptions (type)", "HookDispatch": "src/data/hook.zod.ts#HookDispatch (type)", + "HookDoc": "src/data/hook-api.ts#HookDoc (type)", + "HookDriverPassthroughOptions": "src/data/hook-api.ts#HookDriverPassthroughOptions (type)", "HookEvent": "src/data/hook.zod.ts#HookEvent (const)", "HookEventType": "src/data/hook.zod.ts#HookEventType (type)", + "HookObjectApi": "src/data/hook-api.ts#HookObjectApi (interface)", "HookParsed": "src/data/hook.zod.ts#HookParsed (type)", + "HookQuery": "src/data/hook-api.ts#HookQuery (type)", "HookSchema": "src/data/hook.zod.ts#HookSchema (const)", + "HookUpdateDoc": "src/data/hook-api.ts#HookUpdateDoc (type)", + "HookUpdateOptions": "src/data/hook-api.ts#HookUpdateOptions (type)", "IMPORT_BOOLEAN_FALSE_TOKENS": "src/data/import-coercion.ts#IMPORT_BOOLEAN_FALSE_TOKENS (const)", "IMPORT_BOOLEAN_TRUE_TOKENS": "src/data/import-coercion.ts#IMPORT_BOOLEAN_TRUE_TOKENS (const)", "IMPORT_REFERENCE_TYPES": "src/data/import-coercion.ts#IMPORT_REFERENCE_TYPES (const)", diff --git a/packages/spec/src/data/hook-api.test.ts b/packages/spec/src/data/hook-api.test.ts new file mode 100644 index 00000000000..bd9db9af2f6 --- /dev/null +++ b/packages/spec/src/data/hook-api.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#18163] The published hook `ctx.api` type face, pinned from both sides. + * + * Two independent things can go wrong with {@link HookApi}, and each leg below + * answers exactly one of them: + * + * 1. **It stops describing the object the engine binds.** Pinned as + * assignability against `IScopedContext` — the CHECKED contract ObjectQL's + * `ScopedContext` and `ObjectRepository` carry `implements` clauses + * against. If `HookApi` ever declares a member or a return the checked + * contract cannot satisfy, this file stops compiling. + * + * ⚠️ NOT PINNED HERE, by construction: that the CLASS `ScopedContext` + * satisfies `HookApi`. `packages/spec` must not depend on + * `packages/objectql` (only objectql can execute a dispatch, and spec is + * the contract both sides read), so the class-vs-type leg belongs in + * objectql beside `hook-input-shape-contract.test.ts`, which is where the + * engine's other spec-contract pins live. It has been MEASURED once — this + * card's contract review ran an objectql scratch probe with negative + * controls and both `ScopedContext extends HookApi` and + * `ObjectRepository extends HookObjectApi` hold — but a measurement taken + * once is not a pin, and the standing pin is still owed. + * + * ⛔ The assignability leg below is NOT a substitute for it: it runs the + * other direction. `IScopedObjectRepository` declares no `delete`, so + * "`HookApi` is a usable `IScopedContext`" cannot stand in for "the object + * the engine builds is a usable `HookApi`". + * + * 2. **The option bags drift from the engine's accepted vocabulary.** Every + * shape is derived by `Omit`/`Pick` from the `Engine*Options` schemas the + * engine's own per-method legal-key sets are pinned against + * (`engine-unknown-option.test.ts` asserts each `ENGINE_*_OPTION_KEYS` set + * equals its schema's shape). The runtime leg below pins each schema's key + * set as KEPT ∪ OMITTED, so a seventh key added to a schema lands red here + * until someone decides which side of the split it is on — the same + * discipline the engine applies to `RPC_QUERY_ALIAS_SLOTS`. + * + * The `@ts-expect-error` pins are real checks here: `tsconfig.test.json` + * compiles this layer (`pnpm --filter @objectstack/spec check:test-typecheck`), + * so deleting a directive turns the gate red rather than leaving it green. + */ + +import { describe, expect, it } from 'vitest'; + +import { + EngineCountOptionsSchema, + EngineDeleteOptionsSchema, + EngineQueryOptionsSchema, + EngineUpdateOptionsSchema, +} from './data-engine.zod'; +import type { + EngineTransactionInfo, + EngineTransactionOptions, + HookApi, + HookCountQuery, + HookDeleteOptions, + HookObjectApi, + HookQuery, + HookUpdateOptions, +} from './hook-api'; +import type { IScopedContext, IScopedObjectRepository } from '../contracts/scoped-context'; + +/** `true` only when every `A` is a usable `B`. */ +type Assignable = [A] extends [B] ? true : false; + +const shapeKeys = (schema: unknown): string[] => + Object.keys((schema as { shape: Record }).shape).sort(); + +describe('HookApi — the published hook ctx.api face', () => { + describe('stays a usable IScopedContext', () => { + it('HookApi satisfies the checked implementation contract', () => { + const apiIsAScopedContext: Assignable = true; + const repoIsAScopedRepository: Assignable = true; + expect([apiIsAScopedContext, repoIsAScopedRepository]).toEqual([true, true]); + }); + + it('a hook can narrow ctx.api to it without going through `unknown`', () => { + // The authoring idiom the export exists for. `HookContext['api']` is + // `IScopedContext | undefined`; this cast has to stay legal, so the two + // faces must remain COMPARABLE — a direct `as` here, never `as unknown as`. + const ctxApi = undefined as unknown as IScopedContext | undefined; + const api = ctxApi as HookApi | undefined; + expect(api).toBeUndefined(); + }); + }); + + describe('the where-only rule', () => { + it('accepts the canonical spellings', () => { + const query: HookQuery = { + where: { status: 'active' }, + fields: ['id', 'name'], + orderBy: [{ field: 'name', order: 'asc' }], + limit: 10, + offset: 20, + }; + expect(Object.keys(query).sort()).toEqual( + ['fields', 'limit', 'offset', 'orderBy', 'where'].sort(), + ); + }); + + it('refuses the alias spellings at compile time', () => { + const withFilter: HookQuery = { + where: { status: 'active' }, + // @ts-expect-error `filter` is the ALIAS of `where`. The engine folds + // the slot and throws when the two spellings carry different values; + // omitting the key makes that hazard a compile error instead. + filter: { status: 'won' }, + }; + const withTop: HookQuery = { + limit: 3, + // @ts-expect-error `top` is the OData alias of `limit`, folded by the + // same slot table and refused on the same value disagreement. + top: 1, + }; + const withContext: HookQuery = { + where: { id: 'a' }, + // @ts-expect-error the repository INJECTS `context` after the spread, + // so a caller-supplied one is discarded before the engine sees it. + context: { isSystem: true }, + }; + expect([withFilter, withTop, withContext].length).toBe(3); + }); + + it('refuses the wire-only spellings the engine rejects at the entry point', () => { + const wireOnly: HookQuery = { + // @ts-expect-error `select` is the wire spelling of `fields`; a direct + // engine call bypasses the RPC fold, so the engine rejects it by name. + select: ['id'], + }; + expect(wireOnly).toBeTruthy(); + }); + }); + + describe('count is not find narrowed by habit', () => { + it('takes `where` and refuses everything else', () => { + const ok: HookCountQuery = { where: { status: 'active' } }; + const withLimit: HookCountQuery = { + // @ts-expect-error `count` honours no pagination — `ENGINE_COUNT_OPTION_KEYS` + // is `{ context, where }` and the engine rejects anything else. + limit: 5, + }; + const withPassthrough: HookCountQuery = { + // @ts-expect-error `count` never forwards its bag to the driver, so the + // pass-through keys that ARE legal on find/update/delete are rejected here. + tenantId: 'org_1', + }; + expect([ok, withLimit, withPassthrough].length).toBe(3); + }); + }); + + describe('write option bags', () => { + it('update carries the predicate form, the observability keys and the pass-throughs', () => { + const bulk: HookUpdateOptions = { + where: { status: 'draft' }, + multi: true, + returning: true, + strictReadonlyWrites: true, + onFieldsDropped: (event) => void event.fields, + tenantId: 'org_1', + }; + const retired: HookUpdateOptions = { + // @ts-expect-error `upsert` is a retired-key tombstone (#8057). It stays + // on the schema to carry its migration text, never on a surface + // published after the retirement. + upsert: true, + }; + expect([bulk, retired].length).toBe(2); + }); + + it('delete carries the predicate form and no update-only keys', () => { + const ok: HookDeleteOptions = { where: { status: 'stale' }, multi: true }; + const wrong: HookDeleteOptions = { + where: { id: 'a' }, + // @ts-expect-error `returning` is an UPDATE option; the engine's delete + // set is `{ context, where, multi }` plus the pass-throughs. + returning: true, + }; + expect([ok, wrong].length).toBe(2); + }); + }); + + describe('nameability — every type this face references structurally is reachable here', () => { + // These three names are imported FROM './hook-api', not from the contracts + // files that declare them, so deleting a re-export line does not merely + // widen the surface: it stops this file compiling and `check:test-typecheck` + // goes red. That is the whole pin — a consumer importing only + // `@objectstack/spec/data` and emitting declarations answers TS2883 without + // them, and `check:entry-nameability` cannot see it (it probes the call + // surface of VALUE exports; `HookApi` is a type). + it('both types the transaction signature references', () => { + const infoIsReachable: Assignable = true; + const optsIsReachable: Assignable = true; + expect([infoIsReachable, optsIsReachable]).toEqual([true, true]); + }); + }); + + describe('drift pin — each derived shape equals KEPT ∪ OMITTED on its schema', () => { + // Restated here on purpose rather than imported: a schema key added later + // has to be DECIDED onto one of the two lists, and this pin is what forces + // the decision instead of letting `Omit` silently widen the published type. + const cases: { name: string; schema: unknown; kept: string[]; omitted: string[] }[] = [ + { + name: 'HookQuery / EngineQueryOptionsSchema', + schema: EngineQueryOptionsSchema, + kept: ['where', 'fields', 'orderBy', 'limit', 'offset', 'search', 'searchFields', 'expand'], + omitted: ['context', 'top', 'cursor', 'distinct'], + }, + { + name: 'HookCountQuery / EngineCountOptionsSchema', + schema: EngineCountOptionsSchema, + kept: ['where'], + omitted: ['context'], + }, + { + name: 'HookUpdateOptions / EngineUpdateOptionsSchema', + schema: EngineUpdateOptionsSchema, + kept: ['where', 'multi', 'returning'], + omitted: ['context', 'upsert'], + }, + { + name: 'HookDeleteOptions / EngineDeleteOptionsSchema', + schema: EngineDeleteOptionsSchema, + kept: ['where', 'multi'], + omitted: ['context'], + }, + ]; + + for (const { name, schema, kept, omitted } of cases) { + it(name, () => { + expect(shapeKeys(schema)).toEqual([...kept, ...omitted].sort()); + }); + } + }); +}); diff --git a/packages/spec/src/data/hook-api.ts b/packages/spec/src/data/hook-api.ts new file mode 100644 index 00000000000..09f44c5b794 --- /dev/null +++ b/packages/spec/src/data/hook-api.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `HookApi` — the typed AUTHORING face of `HookContext.api`, published from + * `@objectstack/spec/data` so an app's `*.hook.ts` never has to re-derive it. + * + * ## Why this exists (#18163, maintainer ruling A of 2026-09-17, batch #147) + * + * The platform already implements this surface; it just never published a type + * an app could import. So every metadata app hand-declared one — the reference + * third-party app carried ~2,358 authored tokens of it in one file, imported by + * 17 hook files — and a hand-declared copy of an engine's option vocabulary + * drifts silently the moment the engine moves. The ruling reads that as a + * STABILITY item, not a feature: one source of truth for engine semantics. + * + * ## What it is NOT: a second dialect of `IScopedContext` + * + * `contracts/scoped-context.ts` declares {@link IScopedContext} — the CHECKED + * IMPLEMENTATION contract. ObjectQL's `ScopedContext` (the class the engine + * builds at every hook dispatch, `buildHookApi`) and its `ObjectRepository` + * carry `implements` clauses against it, so it is verified on every objectql + * build. Its option bags are deliberately `Record`, and that + * file argues at length why: typing them with the engine's own option types + * would make an object literal spelling `filter` a compile error over a call + * the runtime accepts, because the engine folds `filter` to `where`. + * + * This file is the OTHER half of the same fact, and the ruling is what settles + * the trade-off the older file left open: + * + * - `IScopedContext` answers "what members does the object the engine binds + * have?" — evidence-barred, loose in the bags, wired to `implements`. + * - `HookApi` answers "what may a hook author WRITE into those bags?" — the + * canonical spelling only, so the `where`/`filter` mixing hazard is a + * compile error FROM THE PLATFORM'S OWN TYPE instead of a runtime throw. + * + * They do not drift, because every option shape below is DERIVED from the very + * `Engine*Options` schemas the engine's own per-method legal-key sets are + * pinned against (`ENGINE_OPTION_KEY_SETS` in `packages/objectql/src/engine.ts`, + * drift-pinned in `engine-unknown-option.test.ts`). A key added to a schema + * flows into the type the same run it flows into the engine's accepted set. + * `hook-api.test.ts` pins the relationship in both directions. + * + * ## The `where`-only rule, measured + * + * `RPC_QUERY_ALIAS_SLOTS` (`data/data-engine.zod.ts`) declares `filter` as the + * alias of `where` and `top` as the alias of `limit`. Every engine entry point + * folds the `where` slot; the find-shaped ones (`find`/`findOne`) additionally + * fold `limit`. `foldQueryAliasSlots` collapses redundant IDENTICAL spellings + * and REFUSES a slot whose spellings carry DIFFERENT values — the engine turns + * that conflict into a throw naming both spellings. So `{ where, filter }` is + * a coin toss decided by whether the two happen to be deep-equal: silent when + * they agree, a runtime error when they do not. + * + * Omitting the alias keys from these types is what makes it neither. It is the + * ruling's explicit instruction for `filter`, and the SAME measurement carries + * `top`: both are alias spellings of a canonical key, both throw on a value + * disagreement, neither adds any expressive power. Authors spell `where` and + * `limit`. + * + * DECIDED at contract review, not left open: `top` stays omitted. The ruling + * names `filter` only, so the question was whether this type may be narrower + * than the ruling's letter — and the measurement says it is not narrower at + * all. `ENGINE_FIND_OPTION_KEYS` itself has no `top`: the alias is folded and + * DELETED before the legal-key check runs, which is why objectql's own drift + * pin skips it. So `HookQuery` carries the engine's accepted set verbatim, and + * re-adding `top` would put the published face out of step with the engine + * while re-opening for `{ limit, top }` exactly the coin toss the ruling closed + * for `{ where, filter }`. It is also the reversible direction: adding a key + * later is additive, removing one is breaking. + * + * ## What else is deliberately off the bags, each with its reason + * + * - `context` — the repository INJECTS it (`{ ...query, context: this.context }`, + * spread last), so a caller-supplied `context` is overwritten before the + * engine sees it. Declaring a key the seam discards is the declared-≠-enforced + * shape AGENTS.md PD #10 refuses. + * - `cursor` / `distinct` / `upsert` — `retiredKey()` tombstones. They stay on + * the schemas to carry their migration text; the engine rejects them by + * quoting that text. They are not part of an authoring surface published + * after their retirement. + * - `sudo()` — the #5945 exclusion STANDS. It is privilege escalation, no + * document teaches it as first-hook vocabulary, and since #14010 the + * declared way for a hook to run elevated is `Hook.runAs: 'system'`, which + * the engine applies to this very `api`. Publishing it here would be a + * maintainer decision, not a measurement. + * - `aggregate` / `execute` / `create` / `deleteById` on the repository. Real + * methods on the class, outside what this ruling asked to publish; + * `execute` additionally dispatches ELEVATED (`{ ...context, isSystem: true }`, + * #13866), which puts it in `sudo()`'s register rather than the CRUD one. + * They join when a ruling or a measured call site says so. + */ + +import type { + EngineCountOptions, + EngineDeleteOptions, + EngineQueryOptions, + EngineUpdateOptions, +} from './data-engine.zod'; +import type { DriverOptions } from './driver.zod'; +// Type-only, and it must stay that way, for the reason `hook.zod.ts` states at +// its own `contracts/` import: `contracts/` already imports `data/`, so a VALUE +// import here would close a runtime cycle. `import type` is erased. +import type { WriteObservabilityOptions } from '../contracts/data-engine'; +import type { + EngineTransactionInfo, + EngineTransactionOptions, +} from '../contracts/objectql-engine'; + +/** + * The driver-option keys the engine forwards VERBATIM from a read/write option + * bag into the driver options — `ENGINE_DRIVER_PASSTHROUGH_KEYS` in + * `packages/objectql/src/engine.ts`, reused from {@link DriverOptions} rather + * than re-spelled so there is one declaration of each key's type. + * + * ⚠️ They are legal on `find` / `findOne` / `update` / `delete` and NOT on + * `count`, which forwards no bag at all and whose legal set is `where` alone. + * That asymmetry is engine behaviour, it is invisible from any document, and it + * is precisely the kind of thing a hand-written copy gets wrong — which is why + * {@link HookCountQuery} below is the one shape that does not carry these. + * + * `bypassTenantAudit` is diagnostics-only by declaration ("never changes what + * the write touches") and `preserveAudit` covers historical imports; neither is + * an authorization switch, so unlike `sudo()` they are ordinary declared + * vocabulary rather than an escalation this surface would be advertising. + */ +export type HookDriverPassthroughOptions = Pick< + DriverOptions, + 'transaction' | 'tenantId' | 'tenantIds' | 'timezone' | 'bypassTenantAudit' | 'preserveAudit' +>; + +/** + * The query bag `ctx.api.object(n).find()` / `.findOne()` accept. + * + * `EngineQueryOptions` minus the injected `context`, minus the `top` alias and + * minus the two tombstones, plus the driver pass-through keys the engine + * forwards. There is NO `filter` key — spell the predicate `where`. + * + * `findOne` additionally REFUSES an unpredicated query at runtime (#4419): it + * reads a single row, so an empty predicate answers the object's FIRST row + * rather than nothing. Say which row you want with `where`, a `search`, or an + * `orderBy`; when any row will do, that is `find({ limit: 1 })`. + */ +export type HookQuery = Omit & + HookDriverPassthroughOptions; + +/** + * The query bag `ctx.api.object(n).count()` accepts — `where` and nothing else. + * + * ⛔ Not {@link HookQuery} narrowed by habit: `count` never forwards its bag to + * the driver, so the pass-through keys that are legal on every other method are + * REJECTED here (`ENGINE_COUNT_OPTION_KEYS` is `{ context, where }`), and so are + * `limit` / `orderBy` / `fields`, which a count honours nowhere. + */ +export type HookCountQuery = Omit; + +/** + * A record payload a hook write carries. + * + * DECIDED at contract review: this stays `Record` of `string` to `unknown`, + * and is NOT widened to the `any`-valued or `object`-valued form. + * + * What the narrow form costs, measured: a payload whose type is an INTERFACE is + * refused — `TS2345: Index signature for type 'string' is missing in type 'X'` + * — because TypeScript grants an implicit index signature to a type alias and + * not to an interface. The engine and `IScopedObjectRepository` both accept it, + * so this is the one shape in this file that sits narrower than the seam. + * + * Kept anyway, for three reasons. It fails LOUDLY and at the authoring site, + * never silently at the driver. The remedy is one word at the call site — + * declare the payload as a `type` rather than an `interface`, or spread it + * (`insert({ ...record })`, which is what a hook writing from `ctx.input` + * already does, and which compiles today). And it is the REVERSIBLE direction: + * widening later is additive, while narrowing later would break every consumer + * that had annotated a value as `HookDoc` and indexed it — the same structural + * argument that settled `top` above. + */ +export type HookDoc = Record; + +/** + * The payload `update` / `updateById` take. + * + * The single-record `update` form puts the primary key INSIDE the payload — + * `update({ id, ...fieldsToChange })` — because the repository reads the key + * out of it. `updateById` takes the id as its own first argument instead. + */ +export type HookUpdateDoc = HookDoc; + +/** + * The options bag `ctx.api.object(n).update()` accepts. + * + * `EngineUpdateOptions` minus the injected `context` and the `upsert` + * tombstone, plus {@link WriteObservabilityOptions} (`onFieldsDropped`, + * `strictReadonlyWrites` — contract-declared, deliberately outside the + * serializable schema) and the driver pass-through keys. + * + * The bulk form is `update(data, { where, multi: true })`; there is no + * `updateMany`. + */ +export type HookUpdateOptions = Omit & + WriteObservabilityOptions & + HookDriverPassthroughOptions; + +/** The options bag `ctx.api.object(n).delete()` accepts. */ +export type HookDeleteOptions = Omit & + HookDriverPassthroughOptions; + +/** + * A repository bound to ONE object and to the calling hook's execution context + * — what `ctx.api.object(name)` hands back. + * + * Scoping is the point: a write through here goes down the engine's normal + * path and is therefore gated by the TARGET object's permission and sharing + * rules, not by whoever happens to be elevated. + * + * Every return shape below MIRRORS the declaration the platform already + * publishes for the same seam — `IScopedObjectRepository` where it declares the + * member, `IDataEngine` where it does not (`delete`) — rather than answering + * the same question a second way. + */ +export interface HookObjectApi { + /** + * Read every record the query selects. + * + * `Promise` mirrors `IScopedObjectRepository.find` and + * `IDataEngine.find`; narrowing it here would make this face disagree with + * the two declarations it forwards through. + */ + find(query?: HookQuery): Promise; + + /** Read the ONE record the query selects, or `null`. */ + findOne(query?: HookQuery): Promise | null>; + + /** Count the records the query selects. */ + count(query?: HookCountQuery): Promise; + + /** Insert one record, or an array of records. */ + insert(data: HookDoc | HookDoc[]): Promise; + + /** + * Update records: the record for the single-record form, the affected-row + * count for the predicate form (`{ where, multi: true }`), `null` when the + * write matched nothing. + */ + update( + data: HookUpdateDoc, + options?: HookUpdateOptions, + ): Promise | number | null>; + + /** + * Update a single record by id — the id travels as the first argument. + * + * Answers the written record, or `null` when the id matched nothing. A falsy + * id is not a narrower answer but a REFUSAL: `0` and `''` identify no row, so + * the dispatch rejects and the call throws. + */ + updateById(id: string | number, data: HookUpdateDoc): Promise | null>; + + /** + * Delete records — `{ where, multi: true }` for the predicate form. + * + * `Promise` is what `IDataEngine.delete` declares, one door + * down from this method. + */ + delete(options?: HookDeleteOptions): Promise; +} + +/** + * The scoped cross-object API a hook reaches through `ctx.api`. + * + * It carries NO top-level `insert` / `update` / `find`: a caller names the + * object first and operates on the repository that comes back + * (`ctx.api.object('task').insert(…)`), which is what makes the scoping + * legible — every operation is addressed to a named object. + * + * `ctx.api` is declared `IScopedContext | undefined` on `HookContext`, so a + * hook narrows to this face at the top of its handler: + * + * ```ts + * import type { HookApi } from '@objectstack/spec/data'; + * + * const api = ctx.api as HookApi | undefined; + * if (!api) return; + * const owner = await api.object('user').findOne({ where: { id: ctx.input.owner } }); + * ``` + */ +export interface HookApi { + /** The repository for `name`, bound to this context. */ + object(name: string): HookObjectApi; + + /** + * Run `callback` inside one driver transaction: committed when it returns, + * rolled back when it throws. + * + * The callback receives a NEW `HookApi` whose operations share the + * transaction handle — reach objects through THAT context (`tx.object(…)`), + * not the outer one, or the writes land outside the transaction. + * + * The second parameter is declared because the PRODUCER hands it + * unconditionally (`ScopedContext.transaction`, #5696). Contravariance keeps + * the zero- and one-argument callbacks authors actually write assignable, so + * the truthful signature is also the more permissive one. + */ + transaction( + callback: (trxCtx: HookApi, info: EngineTransactionInfo) => Promise, + opts?: EngineTransactionOptions, + ): Promise; +} + +/** + * [BLOCKING finding of this card's contract review] The types this entry's own + * public declarations reference STRUCTURALLY, re-exported so they are nameable + * from the entry that publishes them. + * + * The governing text is the maintainer ruling of 2026-08-23 on #11350, recorded + * in `packages/spec/scripts/check-entry-nameability.ts` and chartered + * 2026-08-25 on #11709: a type that appears structurally in an entry's public + * declarations must be nameable from that same entry. + * + * Measured in the consumer shape this card exists to serve — a program that + * imports ONLY `@objectstack/spec/data` and emits declarations: + * + * ``` + * export const inTx = (api: HookApi) => + * api.transaction(async (tx, info) => ({ tx, info })); + * + * error TS2883: The inferred type of 'inTx' cannot be named without a + * reference to 'EngineTransactionInfo'. This is likely not portable. + * ``` + * + * The second position — `transaction`'s `opts` — answers the same way for + * `EngineTransactionOptions`. Those two are what this card owes, and they are + * what this file exports: they are names THIS card's own new declarations + * introduced to the entry. + * + * ⛔ A THIRD instance of the same defect is deliberately NOT closed here, and + * the reason is a measurement rather than a scope reflex. `HookContext.api` has + * leaked `IScopedContext` off this entry since #5945 — pre-existing, present on + * this card's base and unchanged by it. Closing it is not the one-line job it + * looks like: + * + * - It needs TWO names, not one. `IScopedContext.object(name)` returns + * `IScopedObjectRepository`, so with `IScopedContext` exported alone a + * consumer writing `ctx.api.object('deal')` still answers TS2883 on the + * repository — measured, at head, with the three-name variant applied. A + * one-name patch publishes a HALF closure that READS closed, which is the + * declared-not-enforced shape this repo refuses. + * - So it is a TWO-name change to a face this card does not own. Both names + * are declared in `contracts/scoped-context.ts` and neither is introduced + * to this entry by anything in this diff: `HookContext.api` has carried + * the leak since #5945, on this card's base exactly as on its head. What + * this file owes is the two names its OWN new declarations introduced. + * + * So: fixed here, the instances this card created; reported, the pre-existing + * one, with the measurement that it takes two names rather than one — which is + * the part a reader would otherwise get wrong. It is a card of its own, with + * its own review, not a rider on a FAIL remediation. + * + * ⛔ `check:entry-nameability` is NOT the instrument that answers this. By its + * own docblock it probes the CALL surface of VALUE exports that have a call + * signature; `HookApi` is a type, so no probe of that gate ever reaches + * `api.transaction(...)`. It runs green here and is blind to this by + * construction — the reachable radius is value exports, and these three names + * are a known target outside it. The instrument that answers is a consumer + * program with `declaration` emit, which is what the excerpt above is. + * + * Type-only re-exports: they add two names to this entry and no runtime byte, + * and each is ONE declaration reachable from two entries rather than two + * declarations sharing a name, which is what `check:dual-source-exports` asks. + */ +export type { EngineTransactionInfo, EngineTransactionOptions } from '../contracts/objectql-engine'; diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index f9a26ff5ab3..6fff4c17ccf 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -150,6 +150,16 @@ export * from './autonumber-format'; export * from './validation.zod'; export * from './hook.zod'; export * from './hook-body.zod'; +// [#18163] The TYPED AUTHORING FACE of `HookContext.api` — `HookApi`, +// `HookObjectApi` and the query / count / update / delete option shapes the +// engine actually accepts. `contracts/scoped-context.ts` declares the CHECKED +// IMPLEMENTATION contract the engine's `ScopedContext` carries an `implements` +// clause against, with deliberately loose `Record` bags; this +// is the other half — the same seam with the engine's own option vocabulary, +// derived from the `Engine*Options` schemas so the two cannot drift, and +// carrying NO `filter` key, so the `where`/`filter` mixing the engine refuses +// on a value disagreement is a compile error instead of a runtime throw. +export * from './hook-api'; // The bulk-write hook dispatch contract (ADR-0058 Addendum II) — what a // predicate (`multi: true`) write hands a lifecycle hook in BOTH phases: per-row // dispatch, per-row `previous`, a batch-scoped payload, and one budget ceiling