Skip to content

Commit 3069263

Browse files
committed
docs(spec): document the declarative flat-record surface on the HookContext.input contract table (#7254)
The contract table on `HookContextSchema.input` documented exactly one shape — the raw envelope the engine builds for `engine.registerHook` callers — while addressing readers who only ever meet the other surface. Every declarative hook (a metadata `Hook`, i.e. everything from `defineStack({ hooks })`) is wrapped by `wrapDeclarativeHook` → `installFlatInput`, which swaps `ctx.input` for a Proxy presenting a flat record view. A sandboxed `body` goes one step further: the runner hands the script `unwrapProxyToPlain(engineCtx.input)`, which materialises only what the proxy's `ownKeys` trap exposes — so `input` IS the record, there is no `data` key, and the documented `input.data.<field>` spelling is a TypeError that aborts the caller's write under the default `onError: 'abort'`. Prose only: no behaviour change, no proxy change, and the legal metadata set is byte-identical before and after. Whether the two surfaces should converge is deliberately left undecided. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016R9de1FqP7NvwKvqXi92Gh
1 parent db12b88 commit 3069263

1 file changed

Lines changed: 65 additions & 3 deletions

File tree

packages/spec/src/data/hook.zod.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,15 @@ export const HookContextSchema = lazySchema(() => z.object({
310310
* Input Parameters (Mutable)
311311
* Modify this to change the behavior of the operation.
312312
*
313+
* TWO SURFACES, and they do NOT hand a handler the same `input`. The rows
314+
* immediately below are the RAW ENVELOPE the engine builds — what a handler
315+
* registered through `engine.registerHook` receives, and only that. Every
316+
* DECLARATIVE hook — a metadata `Hook`, i.e. everything an app author writes
317+
* in `defineStack({ hooks })` — is handed a FLAT RECORD VIEW of that
318+
* envelope instead. If you are authoring a `Hook`, the rows you need are the
319+
* DECLARATIVE SURFACE block that follows the envelope rows; read the
320+
* envelope rows as the shape being viewed, not as what you will see.
321+
*
313322
* These shapes are exactly what the engine BUILDS — `packages/objectql`'s
314323
* `engine.ts` is the only producer of a `HookContext`. A key this table
315324
* lists but no producer sets reads back `undefined` at every call site, and
@@ -330,8 +339,61 @@ export const HookContextSchema = lazySchema(() => z.object({
330339
* - delete (bulk, multi:true) — before, PER MATCHED ROW: { id: ID, options: EngineDeleteOptions }
331340
* - delete (bulk, multi:true) — after, PER MATCHED ROW: { id: ID, options: DriverOptions }
332341
*
342+
* DECLARATIVE SURFACE — what an app author is actually handed
343+
*
344+
* `bindHooksToEngine` wraps every metadata `Hook` in `wrapDeclarativeHook`,
345+
* which calls `installFlatInput` (`packages/objectql/src/hook-wrappers.ts`)
346+
* for the duration of the handler call: `ctx.input` is swapped for a Proxy
347+
* presenting a flat record view over the envelope above, then restored in a
348+
* `finally`, so the engine's own downstream `input.data` read still picks up
349+
* whatever the handler wrote. That produces TWO shapes, not one — measured
350+
* (#7254) on a real kernel and the real QuickJS runner, with persisted-row
351+
* assertions:
352+
*
353+
* - declarative `handler` (code): `input.<field>` IS the record field. Reads
354+
* of any non-wrapper key resolve against `data`; writes ALWAYS land in
355+
* `data` (created if missing), which is how `input.field = value` reaches
356+
* the driver — and on a bulk write that IS the one batch-scoped payload
357+
* (D3 below), so the flat spelling scopes a rewrite no better than
358+
* `input.data.field` did. The wrapper keys `data` / `id` / `options` / `ast` still
359+
* pass THROUGH to the envelope, so `input.data.<field>` also happens to
360+
* work here — redundantly, and it is the spelling that breaks on the other
361+
* surface. Enumeration is flat-only: `Object.keys(input)`, spread and
362+
* `for…in` list the record fields and hide the wrapper keys (the proxy's
363+
* `ownKeys` trap), so a diff written as
364+
* `Object.keys(input).filter(k => input[k] !== previous[k])` sees fields.
365+
* - declarative `body` (L2 sandboxed JS): `ctx.input` IS the flat record —
366+
* a plain snapshot the runner takes as `unwrapProxyToPlain(engineCtx.input)`
367+
* (`packages/runtime/src/sandbox/body-runner.ts`), i.e. `Object.entries`
368+
* over that proxy, so it materialises exactly what `ownKeys` exposes and
369+
* nothing else. There is NO `data` key at all: `input.data` is `undefined`,
370+
* and the envelope spelling `input.data.<field>` is a **TypeError** — which
371+
* ABORTS the caller's write on a hook whose `onError` is `abort` — which is
372+
* this schema's DEFAULT, and what shipped showcase body hooks declare
373+
* explicitly. `id`, `options` and `ast` are
374+
* absent for the same reason; on `find` and `delete`, whose envelopes carry
375+
* no `data`, the whole snapshot is `{}`. A body that needs the row reads
376+
* `ctx.previous` — the pre-image, `id` included, bound on update and delete.
377+
* Writes the script makes to `ctx.input` are copied back onto the live proxy
378+
* after it returns (`applyMutationsToInput`), so `input.field = value` still
379+
* lands in `data` through the same `set` trap.
380+
*
381+
* There is therefore no single spelling that works everywhere:
382+
* `input.<field>` is correct on BOTH declarative surfaces, and
383+
* `input.data.<field>` only off the raw `registerHook` envelope. #7225 is
384+
* what naming just one half costs: a careful reader holding only the envelope
385+
* rows concluded that three shipped, working example hooks were silent
386+
* no-ops, and prescribed re-spelling them to `input.data` — which would have
387+
* converted the showcase's public web-to-lead insert into a hard refusal of
388+
* every submission. The two declarative shapes are pinned against a real
389+
* kernel in `examples/app-showcase/test/hook-body-persisted-writes.test.ts`
390+
* (#7258); the envelope rows stay pinned in objectql's
391+
* `hook-input-shape-contract.test.ts`. Whether the two surfaces should
392+
* CONVERGE is a separate question, deliberately not decided here (#7254) —
393+
* the split is stated so it cannot be mistaken for an oversight.
394+
*
333395
* PHASE — `input.options` is the one slot whose TYPE depends on when you read
334-
* it, on every path above. The engine builds the context with the CALLER's
396+
* it, on every envelope path above. The engine builds the context with the CALLER's
335397
* own options bag (`EngineQueryOptions` / `DataEngineInsertOptions` /
336398
* `EngineUpdateOptions` / `EngineDeleteOptions`) and only AFTER the `before*`
337399
* handlers return — before the driver call — merges the driver-facing keys
@@ -340,7 +402,7 @@ export const HookContextSchema = lazySchema(() => z.object({
340402
* `after*` handler and the driver read the `DriverOptions` view. The merge is
341403
* ADDITIVE — it spreads the caller's bag and adds keys, never strips one — so
342404
* the widening is one-way and nothing a `before*` handler saw disappears.
343-
* #5997 corrected the two `before` rows above, which had said `DriverOptions`
405+
* #5997 corrected the two `before` envelope rows above, which had said `DriverOptions`
344406
* (a type that declares no `where` and no `multi`): that is not what the
345407
* engine builds there, and not what the two consumers named below read.
346408
* Measured and pinned in the same contract test as the rest of this table.
@@ -354,7 +416,7 @@ export const HookContextSchema = lazySchema(() => z.object({
354416
* (D1–D7) with its budget ceiling is `data/bulk-write-hook-conformance.ts`
355417
* (ADR-0058 Addendum II).
356418
*
357-
* Two things a reader of the rows above still has to know:
419+
* Two things a reader of the envelope rows above still has to know:
358420
*
359421
* - The PAYLOAD stays BATCH-scoped (D3). Every per-row `beforeUpdate`
360422
* context carries THE one payload, not a copy — `driver.updateMany` takes

0 commit comments

Comments
 (0)