|
| 1 | +# 0010 — Typed mutations via a collection-row manifest on `SyncRegistry` |
| 2 | + |
| 3 | +**Status:** Accepted. Types the mutation-handler surface introduced by |
| 4 | +[ADR-0007](./0007-author-owned-schema-register-sync.md). Commands |
| 5 | +(`defineCommand`) are explicitly out of scope (a follow-up). |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +A mutation handler receives `op.cols` as `Record<string, unknown>` — the wire |
| 10 | +type (`MutOp`, `frames.ts`). So every handler casts: `op.cols as Message`. That |
| 11 | +is repetitive, and it is *a lie to the compiler* — an unchecked assertion with |
| 12 | +no runtime backing. We want `op` typed by the collection's row type **and** the |
| 13 | +op kind: |
| 14 | + |
| 15 | +- `insert` → `cols: Row` (the full row, ADR-0001 D19) |
| 16 | +- `update` → `cols: Partial<Row>` (top-level patch, ADR-0002 C6) |
| 17 | +- `delete` → no `cols` (just `key`) |
| 18 | + |
| 19 | +This is **purely a type-level concern**: runtime dispatch is string-keyed and |
| 20 | +untyped (`mutations.get(\`${collection}:${op.type}\`).execute({ op, … })`), and |
| 21 | +the row type has no runtime presence. So the change touches signatures only — |
| 22 | +zero runtime behaviour change — and is verified by type-level tests. |
| 23 | + |
| 24 | +### The constraint that decided the shape |
| 25 | + |
| 26 | +The natural idea — `.defineCollection<Message>({ table: "messages", pk: "id" })`, |
| 27 | +accumulating `{ messages: Message }` so mutations infer the row from the |
| 28 | +collection name — **cannot compile**, for two converging reasons (both verified |
| 29 | +against `tsc --strict`, see below): |
| 30 | + |
| 31 | +1. **TypeScript has no partial type-argument inference** |
| 32 | + ([microsoft/TypeScript#26242](https://github.com/microsoft/TypeScript/issues/26242)). |
| 33 | + The moment you write an explicit `<Message>`, TS stops inferring the other |
| 34 | + type params, so the table-name literal can't be captured. |
| 35 | +2. **The row type has no runtime witness** in `{ table, pk }` — nothing to infer |
| 36 | + it *from* — so it must be an explicit annotation somewhere; which trips (1). |
| 37 | + |
| 38 | +A wall probe confirmed it: with `defineCollection<Row, Table extends string = string>`, |
| 39 | +calling `<Message>(…)` widens `Table` to `string`, yielding `Record<string, Message>` |
| 40 | +— collections become indistinguishable. |
| 41 | + |
| 42 | +So the only question is **where the one explicit row annotation lives.** Three |
| 43 | +homes compile (all verified); a fourth (schema) was rejected. |
| 44 | + |
| 45 | +## Options (all compile-tested) |
| 46 | + |
| 47 | +- **A — per mutation:** `.defineMutation<Message>({ collection, type, execute })`, |
| 48 | + the arg a discriminated union keyed on `type` (so no second inference is |
| 49 | + needed; `cols` is precise per op). Clean single `<>`, co-located with the |
| 50 | + handler. **But:** the row type is repeated on every mutation, and nothing ties |
| 51 | + `collection: "messages"` to `Message` (same trust as `as`, just centralised). |
| 52 | +- **B1 — per collection (curry):** `.defineCollection<Message>()({ table, pk })`. |
| 53 | + Declared once, co-located, links collection→row, and enforces *define-before- |
| 54 | + mutate* at compile time. **But:** the `()()` curry on every collection. |
| 55 | +- **B2 — constructor manifest:** `new SyncRegistry<TUser, Env, { messages: Message }>()`, |
| 56 | + then plain `.defineCollection`/`.defineMutation` with everything inferred. **The |
| 57 | + only option that types *both* call sites** — `pk` against `keyof Row` and the |
| 58 | + table/collection names against the manifest, *and* `op.cols` per op — with no |
| 59 | + curry. **Cost:** the manifest sits apart from the `.defineCollection` calls |
| 60 | + (TS checks they agree), and you specify `Env` to reach the third slot. |
| 61 | +- **B3 — schema-driven (rejected):** `.defineCollection({ table, pk, schema })` |
| 62 | + infers `Row` from a [Standard Schema](https://standardschema.dev/) value *and* |
| 63 | + validates inbound `cols` at runtime. Rejected: it adds no real **safety** — SQL |
| 64 | + injection is already prevented by parameterised binding (values are never |
| 65 | + interpolated; identifiers are validated at registration, ADR-0007), and |
| 66 | + malformed input is already rejectable in `authorize` — while taxing the |
| 67 | + **write hot path** with a per-mutation validator, against the light/streaming |
| 68 | + ethos. Input validation, where wanted, belongs in `authorize`. |
| 69 | + |
| 70 | +## Decision |
| 71 | + |
| 72 | +**B2 — the constructor manifest.** |
| 73 | + |
| 74 | +```ts |
| 75 | +class SyncRegistry<TUser = unknown, Env = unknown, |
| 76 | + TCols extends Record<string, unknown> = Record<string, unknown>> { |
| 77 | + defineCollection<Name extends keyof TCols & string>( |
| 78 | + def: { table: Name; pk: PkOf<TCols[Name]> }, |
| 79 | + ): this |
| 80 | + defineMutation<Name extends keyof TCols & string, T extends RowOp>( |
| 81 | + def: { |
| 82 | + collection: Name |
| 83 | + type: T |
| 84 | + authorize?: (ctx: MutationCtx<TUser, Env, OpFor<T, TCols[Name]>>) => void | Promise<void> |
| 85 | + execute: (ctx: MutationCtx<TUser, Env, OpFor<T, TCols[Name]>>) => void |
| 86 | + afterCommit?: (ctx: MutationCtx<TUser, Env, OpFor<T, TCols[Name]>>) => unknown |
| 87 | + }, |
| 88 | + ): this |
| 89 | +} |
| 90 | +type OpFor<T extends RowOp, Row> = |
| 91 | + T extends "insert" ? { type: "insert"; key: string; cols: Row } |
| 92 | + : T extends "update" ? { type: "update"; key: string; cols: Partial<Row> } |
| 93 | + : { type: "delete"; key: string; cols?: undefined } |
| 94 | +// PkOf<Row> = [keyof Row] extends [never] ? string : keyof Row & string |
| 95 | +``` |
| 96 | +
|
| 97 | +- **Non-breaking for the untyped path.** `TCols` defaults to |
| 98 | + `Record<string, unknown>`, so `new SyncRegistry<Claims>()` still compiles; an |
| 99 | + unannotated collection falls back to `cols: unknown` (the author casts, as |
| 100 | + today). `PkOf` degrades to `string` when the row is unknown. |
| 101 | +- **Zero runtime change.** The `collections`/`mutations` Maps stay string-keyed; |
| 102 | + the generic builder casts the typed def into the erased stored |
| 103 | + `MutationDef` at the storage boundary (one deliberate, isolated `as`). |
| 104 | + `MutationCtx` gains a third type param `TOp = MutOp` (default preserves |
| 105 | + existing internal call sites). Dispatch is untouched. |
| 106 | +
|
| 107 | +**Why B2 over A and B1.** Over **A**: A re-states the row type per mutation and |
| 108 | +can't check collection↔row; B2 declares it once and types both sides. Over |
| 109 | +**B1**: B1's co-location is nice but forces `()()` on every collection; B2 has no |
| 110 | +curry, and the manifest-apart-from-calls gap is itself type-checked |
| 111 | +(`table extends keyof TCols`). The deciding vote was the `()()` ergonomics |
| 112 | +against B2's single up-front declaration. |
| 113 | + |
| 114 | +## Verification |
| 115 | + |
| 116 | +The four ideas were compiled under `tsc --noEmit --strict` before deciding: |
| 117 | +A and B2 type-check with precise per-op `cols` (insert `Row`, update |
| 118 | +`Partial<Row>`, delete no `cols`); a generic *on `execute`* fails (the param is |
| 119 | +caller-chosen and opaque in the body); the accumulating `defineCollection<Row>` |
| 120 | +widens the table to `string`. These become permanent **type-level tests** |
| 121 | +(`tsc` expect-pass + `@ts-expect-error` expect-fail) alongside the unchanged |
| 122 | +runtime suite. |
| 123 | + |
| 124 | +## Consequences |
| 125 | + |
| 126 | +- Authors declare row types once (the manifest); handlers get precise |
| 127 | + `op.cols`/`op.key`; the `as Message` casts disappear — including the ones in |
| 128 | + the README quick-start (added during the README pass as a stopgap). |
| 129 | +- The untyped path is unchanged at runtime and only mildly narrower at compile |
| 130 | + time (`cols: unknown` vs `Record<string, unknown>`); our own call sites cast, |
| 131 | + so nothing breaks. |
| 132 | +- Type-level tests join the suite — a new category for this repo. |
| 133 | + |
| 134 | +## Out of scope |
| 135 | + |
| 136 | +- **Typed command `args`** (`defineCommand`). Commands are a different shape |
| 137 | + (free-form args, not a collection row), and their API status warrants its own |
| 138 | + review. Deferred to a follow-up. |
0 commit comments