@rindle/client 0.3.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ // Isomorphic mutation ops — the backend-agnostic "rindle mutation" vocabulary
2
+ // (MUTATORS-ISOMORPHIC plan). A mutator names WHAT it writes (insert/update/upsert/
3
+ // insertIgnore/delete over a keyed row), not HOW — the server renders dialect SQL and the
4
+ // client applies to its local engine. This lives in `@rindle/client` (the leaf both tiers
5
+ // import); the SQL renderer that consumes it is server-only (`@rindle/api-server`).
6
+
7
+ import type { Ast } from "./ast.ts";
8
+ import type { ColsMap, InsertOf, PkColsOf, PkMap, PkOf, Schema, UpdateOf } from "./schema.ts";
9
+ import type { WireValue } from "./types.ts";
10
+
11
+ /** A keyed row: column name → cell. The ergonomic write shape (validated against the schema at
12
+ * runtime). JSON columns carry their raw JSON string (a {@link WireValue}), never a parsed object. */
13
+ export type KeyedRow = Record<string, WireValue>;
14
+
15
+ /** One structured write intent — the discriminated union that mirrors the write half of the client
16
+ * `MutationTx` 1:1. Keyed (column-name addressed), so it is independent of column order. */
17
+ export type MutationOp =
18
+ | { kind: "insert"; table: string; row: KeyedRow } // a FULL row (every column present)
19
+ | { kind: "upsert"; table: string; row: KeyedRow } // full row; replace non-pk cols on pk conflict
20
+ | { kind: "insertIgnore"; table: string; row: KeyedRow } // full row; do nothing on pk conflict
21
+ | { kind: "update"; table: string; row: KeyedRow } // pk cols + the non-pk cols to change
22
+ | { kind: "delete"; table: string; pk: KeyedRow }; // pk cols only
23
+
24
+ /** The ASYNC server-side write surface a mutator runs against. Semantically the twin of the client's
25
+ * synchronous `MutationTx` write methods — same names, same keyed-row arguments — but every op is a
26
+ * Promise so a Postgres backend can execute it live against an open transaction (read-your-writes).
27
+ * A rindle/daemon backend resolves writes immediately (accumulate) and serves `row` from committed
28
+ * state. `insert` requires every column; `update`/`delete` require the pk columns. */
29
+ export interface ServerWriteTx {
30
+ /** Insert a FULL row (every column named; missing or unknown columns throw). */
31
+ insert(table: string, row: KeyedRow): Promise<void>;
32
+ /** Update the row identified by the pk columns; only the named non-pk columns change. A missing
33
+ * row is a NO-OP. */
34
+ update(table: string, row: KeyedRow): Promise<void>;
35
+ /** Insert, or fully replace when the pk already exists (a FULL row, like `insert`). */
36
+ upsert(table: string, row: KeyedRow): Promise<void>;
37
+ /** Insert a FULL row, or do nothing if the pk already exists (the SQL twin of the client
38
+ * `if (!tx.row(pk)) tx.insert(row)` pattern). */
39
+ insertIgnore(table: string, row: KeyedRow): Promise<void>;
40
+ /** Delete the row identified by the pk columns. A missing row is a NO-OP. */
41
+ delete(table: string, pk: KeyedRow): Promise<void>;
42
+ /** Read one row by primary key, through the OPEN transaction on both backends
43
+ * (read-your-writes: live on Postgres; via an interactive mutation session on the daemon,
44
+ * DAEMON-INTERACTIVE-TXN-DESIGN.md §5.3). */
45
+ row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined>;
46
+ }
47
+
48
+ // --------------------------------------------------------------------------- shared (generator) mutators
49
+ //
50
+ // The isomorphic mutator seam (MUTATORS-ISOMORPHIC plan §"one body, two tiers"): a mutator is a
51
+ // GENERATOR that `yield`s effects instead of an async/sync function. A generator is neither sync nor
52
+ // async — the tier's DRIVER decides — so ONE body runs synchronously against the browser wasm engine
53
+ // AND against a live async Postgres transaction on the server. Writes are `yield`ed and pipelined by
54
+ // the driver (never individually awaited by the body); a read is the thing that suspends. Three read
55
+ // shapes: `yield tx.row(...)` (a point pk read, evaluating to the row), `yield tx.query(builder)` (a
56
+ // full `where`/`orderBy`/`limit`/join query, evaluating to its rows), and `yield tx.all([...])` (a
57
+ // fan-out — Promise.all on the server, in-order on the client). All are order-preserving on both
58
+ // tiers, so the body stays deterministic. Every read is read-your-writes (sees this mutator's own
59
+ // writes-so-far). `tx.query` runs the SAME query engine live queries use (the wasm IVM on the client;
60
+ // `@rindle/query-compiler`'s SQLite SELECT through the open session on the daemon backend).
61
+
62
+ /** A point read a generator mutator yields; the driver resolves it and feeds the row back through
63
+ * `gen.next(row)`. Read-your-writes on every tier: the client reads its local engine, and both
64
+ * server backends read the open transaction (the daemon via an interactive mutation session). */
65
+ export type ReadEffect = { kind: "row"; table: string; pk: KeyedRow };
66
+
67
+ /** A fan-out a generator mutator yields: run several effects "together". The server driver resolves
68
+ * them with `Promise.all`; the client driver runs them in array order (already synchronous). Results
69
+ * return in the SAME order on both tiers, so the mutator stays deterministic. The `yield` evaluates
70
+ * to `(KeyedRow | undefined)[]` at runtime (cast it — the generator's single next-type is a row). */
71
+ export type BatchEffect = { kind: "all"; effects: readonly YieldEffect[] };
72
+
73
+ /** A row returned by a {@link QueryEffect}: column name → cell, plus each materialized relationship
74
+ * name → its nested row(s) — an array (a plural relationship) or a single row / `null` (a `.one()`
75
+ * relationship). Recursive. Presented identically on both tiers (a `view.data` row of the same
76
+ * query). */
77
+ export type QueryResultRow = { [key: string]: WireValue | QueryResultRow | QueryResultRow[] };
78
+
79
+ /** What {@link IsoTx.query} accepts: a query handle whose `.ast()` lowers to the wire {@link Ast} —
80
+ * exactly what the typed query builder produces (`newQueryBuilder(schema).<table>…`, the tier-agnostic
81
+ * server-scope builder both tiers can construct because it performs no I/O). Structural so the seam
82
+ * need not carry the builder's heavy generics. */
83
+ export type QueryArg = { ast(): Ast };
84
+
85
+ /** A full-shape read a generator mutator yields — a `where`/`orderBy`/`limit`/join query over the
86
+ * state this mutator is mutating (read-your-writes, like {@link ReadEffect} but an arbitrary shape).
87
+ * Evaluates to {@link QueryResultRow}`[]` — ALWAYS an array of the matching rows, in the query's
88
+ * order, on BOTH tiers (a root `.one()` is not unwrapped here: take `[0]`). The single next-type is
89
+ * a row, so cast the yield (`(yield tx.query(q)) as unknown as QueryResultRow[]`), same as `all`. */
90
+ export type QueryEffect = { kind: "query"; query: QueryArg };
91
+
92
+ /** Everything a generator mutator may `yield`: a write {@link MutationOp}, a point {@link ReadEffect},
93
+ * a full-query {@link QueryEffect}, or a {@link BatchEffect} fan-out. */
94
+ export type YieldEffect = MutationOp | ReadEffect | BatchEffect | QueryEffect;
95
+
96
+ /** The tier-AGNOSTIC effect factory a generator mutator writes against. Every method just BUILDS an
97
+ * effect to `yield` — it performs no I/O and holds no state, so the single {@link isoTx} instance is
98
+ * shared by every mutator on both tiers; only the driver differs. `insert`/`upsert`/`insertIgnore`
99
+ * take a FULL row; `update`/`delete` take the pk columns (`update` adds the columns to change). */
100
+ export interface IsoTx<S extends ColsMap = ColsMap, P extends Record<string, string> = PkMap<S>> {
101
+ /** Insert a FULL row — nullable columns may be omitted (filled `null`, design 206 §6.2/§7). */
102
+ insert<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;
103
+ /** Update the row identified by its pk columns (REQUIRED); only the named non-pk columns change. */
104
+ update<N extends keyof S & string>(table: N, row: UpdateOf<S[N], PkColsOf<S, P, N>>): MutationOp;
105
+ /** Insert, or fully replace on pk conflict (a FULL row, like {@link IsoTx.insert}). */
106
+ upsert<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;
107
+ /** Insert a FULL row, or do nothing if the pk already exists. */
108
+ insertIgnore<N extends keyof S & string>(table: N, row: InsertOf<S[N]>): MutationOp;
109
+ /** Delete the row identified by its pk columns. */
110
+ delete<N extends keyof S & string>(table: N, pk: PkOf<S[N], PkColsOf<S, P, N>>): MutationOp;
111
+ /** Read one row by primary key (read-your-writes). */
112
+ row<N extends keyof S & string>(table: N, pk: PkOf<S[N], PkColsOf<S, P, N>>): ReadEffect;
113
+ /** Run a full query (`where`/`orderBy`/`limit`/join) over the state this mutator is mutating —
114
+ * read-your-writes, like {@link row} but for arbitrary shapes. Pass a query from the tier-agnostic
115
+ * builder, e.g. `tx.query(q.issue.where("ownerId", "=", ctx.user))` where `q = newQueryBuilder(schema)`.
116
+ * The `yield` evaluates to {@link QueryResultRow}`[]` (cast it — the generator's single next-type is a row). */
117
+ query(query: QueryArg): QueryEffect;
118
+ all(effects: readonly YieldEffect[]): BatchEffect;
119
+ }
120
+
121
+ /** The one shared effect factory (stateless — see {@link IsoTx}). Its methods just BUILD a
122
+ * {@link MutationOp}, so the single instance serves every schema; the generic {@link IsoTx} view is
123
+ * applied at the authoring site (a `json<T>` cell is a parsed object here and is stringified by the
124
+ * funnels, {@link toCell}), hence the cast — the runtime shape is schema-agnostic. */
125
+ export const isoTx: IsoTx = {
126
+ insert: (table: string, row: KeyedRow): MutationOp => ({ kind: "insert", table, row }),
127
+ update: (table: string, row: KeyedRow): MutationOp => ({ kind: "update", table, row }),
128
+ upsert: (table: string, row: KeyedRow): MutationOp => ({ kind: "upsert", table, row }),
129
+ insertIgnore: (table: string, row: KeyedRow): MutationOp => ({ kind: "insertIgnore", table, row }),
130
+ delete: (table: string, pk: KeyedRow): MutationOp => ({ kind: "delete", table, pk }),
131
+ row: (table: string, pk: KeyedRow): ReadEffect => ({ kind: "row", table, pk }),
132
+ query: (query: QueryArg): QueryEffect => ({ kind: "query", query }),
133
+ all: (effects: readonly YieldEffect[]): BatchEffect => ({ kind: "all", effects }),
134
+ } as unknown as IsoTx;
135
+
136
+ /** The minimal per-invocation context a shared mutator sees on BOTH tiers: the acting principal. The
137
+ * server injects its AUTHENTICATED user; the client injects its local user. (The server's own
138
+ * `MutationContext` is a superset of this.) */
139
+ export interface MutatorCtx {
140
+ user: string;
141
+ }
142
+
143
+ /** What a generator mutator IS: `yield tx.<op>()` on every side effect; a `yield tx.row()` expression
144
+ * evaluates to the row. Neither sync nor async — the tier's driver decides, which is what lets one
145
+ * body run synchronously on the client and against a live async transaction on the server. */
146
+ export type MutationGen = Generator<YieldEffect, void, KeyedRow | undefined>;
147
+
148
+ /** A generator (isomorphic) mutator, shared verbatim by both tiers: the client trusts typed `args`,
149
+ * the server parses untrusted args into `Args` before invoking. */
150
+ export type SharedMutator<Args, Ctx extends MutatorCtx = MutatorCtx> = (
151
+ tx: IsoTx,
152
+ args: Args,
153
+ ctx: Ctx,
154
+ ) => MutationGen;
155
+
156
+ /** The minimal arg validator a shared mutator can carry so the SERVER can parse UNTRUSTED wire args
157
+ * before driving it (the client trusts its typed callsites and never calls this). Structural on
158
+ * purpose — a zod schema satisfies it as-is — so `@rindle/client` stays validator-library-agnostic. */
159
+ export interface ArgSchema<Args> {
160
+ parse(raw: unknown): Args;
161
+ }
162
+
163
+ /** A shared mutator that CARRIES its own arg validator, co-located at the def site (`shared(schema,
164
+ * gen)`). The client registers it exactly like a bare generator mutator — the `.args` validator is
165
+ * inert there (typed callsites skip the parse); the server ({@link runSharedMutation}, via the
166
+ * api-server's `sharedApiMutators`) reads `.args` to parse untrusted wire args before driving the
167
+ * SAME body. */
168
+ export type SharedMutatorWithArgs<Args, Ctx extends MutatorCtx = MutatorCtx> = SharedMutator<Args, Ctx> & {
169
+ args: ArgSchema<Args>;
170
+ };
171
+
172
+ /** Co-locate a shared (generator) mutator with the validator for its args — pairing the arg SHAPE
173
+ * with the body that consumes it at ONE site, so neither tier restates it (the client derives its
174
+ * callsite type from `Args`; the server parses untrusted args through `.args`). Returns the SAME
175
+ * generator function with an `args` property attached (`Object.assign` mutates + returns it), so the
176
+ * registered value is byte-for-byte what the client drove before: {@link isGeneratorMutator} still
177
+ * detects it and it still `satisfies ClientRegistry`. */
178
+ export function shared<Args, Ctx extends MutatorCtx = MutatorCtx>(
179
+ args: ArgSchema<Args>,
180
+ run: SharedMutator<Args, Ctx>,
181
+ ): SharedMutatorWithArgs<Args, Ctx> {
182
+ return Object.assign(run, { args });
183
+ }
184
+
185
+ /** Bind a schema to the mutator authoring surface: `const { shared } = defineMutators(schema)`.
186
+ *
187
+ * The returned `shared` is the schema-typed twin of the bare {@link shared} — its `tx` is an
188
+ * {@link IsoTx} parameterized by THIS schema, so `tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`
189
+ * check the table name, every column name, each column's value type, nullable-omit on inserts, and
190
+ * the exact primary-key columns on `update`/`delete` — all at compile time. It registers identically
191
+ * to the bare form (same runtime value, still `satisfies ClientRegistry`, still an isomorphic
192
+ * generator the server drives): only the AUTHORING types tighten. `schema` is read for its TYPE only
193
+ * (never at runtime). Reads (`yield tx.row(...)`) stay loosely typed — a generator's single
194
+ * next-type can't carry a per-table row. */
195
+ /** The typed {@link IsoTx} for a given schema — `IsoTxOf<typeof schema>`. Use it to annotate a helper
196
+ * that a mutator body passes its `tx` to (e.g. `const ensureUser = (tx: IsoTxOf<typeof schema>, …)`),
197
+ * so the helper gets the same table/column/pk typing the `defineMutators` `shared` callback does. */
198
+ export type IsoTxOf<Sch extends Schema> = Sch extends Schema<infer S, infer P> ? IsoTx<S, P> : never;
199
+
200
+ export function defineMutators<S extends ColsMap, P extends Record<string, string>>(_schema: Schema<S, P>) {
201
+ return {
202
+ shared<Args, Ctx extends MutatorCtx = MutatorCtx>(
203
+ args: ArgSchema<Args>,
204
+ run: (tx: IsoTx<S, P>, args: Args, ctx: Ctx) => MutationGen,
205
+ ): SharedMutatorWithArgs<Args, Ctx> {
206
+ // The typed `tx: IsoTx<S, P>` is authoring-only; the driver invokes with the loose `isoTx`
207
+ // singleton (cast at the call boundary, like the bare `shared`), so the value is registry-shaped.
208
+ return Object.assign(run as unknown as SharedMutator<Args, Ctx>, { args });
209
+ },
210
+ };
211
+ }
212
+
213
+ /** True iff `fn` is a generator function (a shared/isomorphic mutator) rather than a plain function —
214
+ * the driver accepts both forms. Detected structurally (native `GeneratorFunction`). */
215
+ export function isGeneratorMutator(fn: unknown): fn is (...args: never[]) => MutationGen {
216
+ return (
217
+ typeof fn === "function" &&
218
+ (fn as { constructor?: { name?: string } }).constructor?.name === "GeneratorFunction"
219
+ );
220
+ }
221
+
222
+ /** A tier's SYNCHRONOUS effect executor (the browser wasm engine): apply a write, resolve a read —
223
+ * both immediate. */
224
+ export interface SyncEffectExec {
225
+ apply(op: MutationOp): void;
226
+ read(table: string, pk: KeyedRow): KeyedRow | undefined;
227
+ query(q: QueryArg): QueryResultRow[];
228
+ }
229
+
230
+ /** Drive a generator mutator SYNCHRONOUSLY (the client, inside the wasm write transaction). Each
231
+ * yielded write applies immediately; each read (point or query) is resolved and fed back; a batch
232
+ * runs in order. */
233
+ export function driveMutationSync(gen: MutationGen, exec: SyncEffectExec): void {
234
+ const run = (eff: YieldEffect): KeyedRow | undefined => {
235
+ if (eff.kind === "row") return exec.read(eff.table, eff.pk);
236
+ if (eff.kind === "query") return exec.query(eff.query) as unknown as KeyedRow | undefined;
237
+ if (eff.kind === "all") return eff.effects.map(run) as unknown as KeyedRow | undefined;
238
+ exec.apply(eff);
239
+ return undefined;
240
+ };
241
+ for (let step = gen.next(); !step.done; step = gen.next(run(step.value)));
242
+ }
243
+
244
+ /** A tier's ASYNCHRONOUS effect executor (the server transaction): every op is a Promise. */
245
+ export interface AsyncEffectExec {
246
+ apply(op: MutationOp): Promise<void>;
247
+ read(table: string, pk: KeyedRow): Promise<KeyedRow | undefined>;
248
+ query(q: QueryArg): Promise<QueryResultRow[]>;
249
+ }
250
+
251
+ /** Drive a generator mutator ASYNCHRONOUSLY (the server, against the open transaction). Writes are
252
+ * awaited (harmless: a single interactive Postgres connection serializes statements anyway, and the
253
+ * daemon backend resolves them instantly), reads (point or query) suspend, and a batch fans out with
254
+ * `Promise.all`. */
255
+ export async function driveMutationAsync(gen: MutationGen, exec: AsyncEffectExec): Promise<void> {
256
+ const run = async (eff: YieldEffect): Promise<KeyedRow | undefined> => {
257
+ if (eff.kind === "row") return exec.read(eff.table, eff.pk);
258
+ if (eff.kind === "query") return (await exec.query(eff.query)) as unknown as KeyedRow | undefined;
259
+ if (eff.kind === "all") return (await Promise.all(eff.effects.map(run))) as unknown as KeyedRow | undefined;
260
+ await exec.apply(eff);
261
+ return undefined;
262
+ };
263
+ for (let step = gen.next(); !step.done; step = gen.next(await run(step.value)));
264
+ }
package/src/schema.ts CHANGED
@@ -8,32 +8,58 @@
8
8
 
9
9
  import type { Arg, Cond } from "./operators.ts";
10
10
  import { fieldCondition } from "./operators.ts";
11
- import type { ColType } from "./types.ts";
11
+ import type { ColType, WireValue } from "./types.ts";
12
12
 
13
- /** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. */
13
+ /** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. A
14
+ * nullable column is a `Col<T | null>` — that is the ONLY thing nullability changes at the type
15
+ * level, so `RowOf` (and the field-condition factory) widen automatically.
16
+ *
17
+ * `optional` is the runtime companion of that phantom: set by {@link ColBuilder.nullable}, it is
18
+ * what the write funnels read to make a nullable column omittable from an insert (filled with
19
+ * `null`, design 206 §6.2). It mirrors the engine's `ColumnDef.optional`
20
+ * (`pragma_table_info.notnull == 0`); absent ⇒ `NOT NULL` / required. */
14
21
  export interface Col<T> {
15
22
  readonly type: ColType;
23
+ readonly optional?: boolean;
16
24
  readonly __t?: T;
17
25
  }
18
26
  export type ColT<X> = X extends Col<infer T> ? T : never;
19
27
  export type AnyCols = Record<string, Col<unknown>>;
20
28
  export type RowOf<C extends AnyCols> = { [K in keyof C]: ColT<C[K]> };
21
29
 
22
- export const string = (): Col<string> => ({ type: "string" });
23
- export const number = (): Col<number> => ({ type: "number" });
24
- export const boolean = (): Col<boolean> => ({ type: "boolean" });
25
- export const json = <T = unknown>(): Col<T> => ({ type: "json" });
30
+ /** The chainable form returned by the column factories: a {@link Col} plus `.nullable()`.
31
+ *
32
+ * `.nullable()` widens the column's value type to `T | null` — its `Row<…>` field becomes
33
+ * `T | null` and sets the runtime {@link Col.optional} marker so it may be omitted from an insert
34
+ * (design 206 §6.2). `rindle schema gen` emits `.nullable()` for every nullable (non-`NOT NULL`) SQL
35
+ * column; you can also call it by hand on a local-only table's columns. It is idempotent and stays
36
+ * chainable. */
37
+ export interface ColBuilder<T> extends Col<T> {
38
+ nullable(): ColBuilder<T | null>;
39
+ }
40
+ const makeCol = <T>(type: ColType, optional = false): ColBuilder<T> => ({
41
+ type,
42
+ ...(optional ? { optional: true } : null),
43
+ nullable: () => makeCol<T | null>(type, true),
44
+ });
45
+
46
+ export const string = <T extends string = string>(): ColBuilder<T> => makeCol<T>("string");
47
+ export const number = <T extends number = number>(): ColBuilder<T> => makeCol<T>("number");
48
+ export const boolean = (): ColBuilder<boolean> => makeCol<boolean>("boolean");
49
+ export const json = <T = unknown>(): ColBuilder<T> => makeCol<T>("json");
26
50
 
27
51
  /** Metadata key on a {@link TableDef} (a `unique symbol`, so no column name collides). */
28
52
  export const SCHEMA: unique symbol = Symbol("rindle.schema");
29
53
 
30
- export interface TableMeta<N extends string = string, C extends AnyCols = AnyCols> {
54
+ export interface TableMeta<N extends string = string, C extends AnyCols = AnyCols, PK extends string = string> {
31
55
  readonly name: N;
32
56
  readonly columns: C;
33
- // Plain `string[]` (NOT `keyof C`): `keyof C` is contravariant, which would make
34
- // `TableMeta` non-covariant in `C` and break `TableDef<concrete>` → `AnyTable`. Key
35
- // validity is enforced on the `primaryKey()` builder method (`K extends keyof C`).
36
- readonly primaryKey: readonly string[];
57
+ // `PK` is an INDEPENDENT param (a plain string union), NOT `keyof C`: `keyof C` would be
58
+ // contravariant and make `TableMeta` non-covariant in `C`, breaking `TableDef<concrete>` →
59
+ // `AnyTable`. Key validity is enforced on the `primaryKey()` builder (`K extends keyof C`); PK is
60
+ // captured there so the mutator tx can type `update`/`delete` pk args (`PkOf`/`UpdateOf`). The
61
+ // default `string` erases it (older 2-arg `TableMeta<N, C>` refs still resolve).
62
+ readonly primaryKey: readonly PK[];
37
63
  /** A **local-only** table (`201-LOCAL-ONLY-TABLES-DESIGN.md`): client-authoritative,
38
64
  * never synced/tracked/rebased. The single marker the whole design keys off (§4) — it is
39
65
  * immutable for the table's lifetime (N2) and never crosses the wire (C2). Absent ⇒ an
@@ -49,8 +75,8 @@ export interface TableOptions {
49
75
  local?: boolean;
50
76
  }
51
77
 
52
- export type TableDef<N extends string, C extends AnyCols> = {
53
- readonly [SCHEMA]: TableMeta<N, C>;
78
+ export type TableDef<N extends string, C extends AnyCols, PK extends string = string> = {
79
+ readonly [SCHEMA]: TableMeta<N, C, PK>;
54
80
  } & {
55
81
  readonly [K in keyof C]: (arg: Arg<ColT<C[K]>>) => Cond<RowOf<C>>;
56
82
  };
@@ -66,28 +92,77 @@ export type AnyTable = TableLike<AnyCols>;
66
92
  * the schema instead of hand-maintaining a parallel twin. */
67
93
  export type Row<T extends AnyTable> = RowOf<T[typeof SCHEMA]["columns"]>;
68
94
 
95
+ /** Flatten an intersection of mapped types into a single object type (preserving `?`/`readonly`) so
96
+ * {@link InsertOf} reads as one clean shape in editor hovers, not `A & B`. */
97
+ type Simplify<T> = { [K in keyof T]: T[K] };
98
+
99
+ /** True for the top types `unknown`/`any` (`unknown extends any` too), where `[null] extends [T]` is
100
+ * vacuously true and so can't tell a `.nullable()` apart. */
101
+ type IsTop<T> = unknown extends T ? true : false;
102
+
103
+ /** Whether column `X` may be OMITTED from an insert: it admits `null` at the type level — EXCLUDING
104
+ * the top types. A bare `json()` is `json<unknown>()`, and `unknown | null` collapses back to
105
+ * `unknown`, erasing whether `.nullable()` was applied; treating it as required is the safe
106
+ * direction (a `NOT NULL` json column is never wrongly made optional). Declare `json<T>()` to make a
107
+ * nullable json column omittable. The runtime companion is `Col.optional` (design 206 §6.2/§7). */
108
+ type InsertOptional<X> = IsTop<ColT<X>> extends true ? false : [null] extends [ColT<X>] ? true : false;
109
+
110
+ /** The INSERT shape of a column map: {@link RowOf} with every NULLABLE column made OPTIONAL (`?`) —
111
+ * it may be omitted and is filled with `null` by both write funnels (design 206 §6.2/§7). `NOT NULL`
112
+ * columns stay required. The insert-side twin of {@link RowOf}, mirroring Drizzle's `$inferInsert`
113
+ * vs `$inferSelect` split. */
114
+ export type InsertOf<C extends AnyCols> = Simplify<
115
+ { [K in keyof C as InsertOptional<C[K]> extends true ? never : K]: ColT<C[K]> } & {
116
+ [K in keyof C as InsertOptional<C[K]> extends true ? K : never]?: ColT<C[K]>;
117
+ }
118
+ >;
119
+
120
+ /** The insert type of a table def: `Insert<typeof issue>` → `{ id: string; … assignee?: string | null }`.
121
+ * The whole-table ergonomic form of {@link InsertOf} (the insert-side twin of {@link Row}). */
122
+ export type Insert<T extends AnyTable> = InsertOf<T[typeof SCHEMA]["columns"]>;
123
+
124
+ /** The exact PRIMARY-KEY columns of a table (each typed), required — the identity a `delete`/`row`,
125
+ * and the WHERE half of an `update`, take. `PK` is the table's pk-column union (the schema's `__pk`). */
126
+ export type PkOf<C extends AnyCols, PK extends string> = { [K in PK & keyof C]: ColT<C[K & keyof C]> };
127
+
128
+ /** The UPDATE shape of a table: its {@link PkOf primary-key columns} REQUIRED (they identify the row)
129
+ * plus every non-pk column OPTIONAL (`?`) and typed — a nullable one stays `T | null` (settable to
130
+ * null), a `NOT NULL` one is `T`. Omitted non-pk columns are left unchanged. */
131
+ export type UpdateOf<C extends AnyCols, PK extends string> = Simplify<
132
+ PkOf<C, PK> & { [K in Exclude<keyof C, PK>]?: ColT<C[K]> }
133
+ >;
134
+
135
+ /** The primary-key column union for table `N` of a schema, read from its `__pk` map `P` and narrowed
136
+ * to `N`'s actual columns (falling back to all columns when `P` doesn't name `N` — e.g. the loose
137
+ * default schema). Feeds {@link PkOf}/{@link UpdateOf} in the typed mutator tx. */
138
+ export type PkColsOf<S extends ColsMap, P extends Record<string, string>, N extends keyof S> = (N extends keyof P
139
+ ? P[N]
140
+ : keyof S[N] & string) &
141
+ keyof S[N] &
142
+ string;
143
+
69
144
  /** `table("issue").columns({ id: string(), … }).primaryKey("id")`. Pass `{ local: true }` for a
70
145
  * {@link TableMeta.local local-only} table (`201-LOCAL-ONLY-TABLES-DESIGN.md`). */
71
146
  export function table<N extends string>(name: N, opts?: TableOptions) {
72
147
  return {
73
148
  columns<C extends AnyCols>(cols: C) {
74
149
  return {
75
- primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C> {
76
- return makeTableDef<N, C>({ name, columns: cols, primaryKey: keys, local: opts?.local });
150
+ primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C, K> {
151
+ return makeTableDef<N, C, K>({ name, columns: cols, primaryKey: keys, local: opts?.local });
77
152
  },
78
153
  };
79
154
  },
80
155
  };
81
156
  }
82
157
 
83
- function makeTableDef<N extends string, C extends AnyCols>(meta: TableMeta<N, C>): TableDef<N, C> {
158
+ function makeTableDef<N extends string, C extends AnyCols, PK extends string>(meta: TableMeta<N, C, PK>): TableDef<N, C, PK> {
84
159
  return new Proxy({} as Record<string | symbol, unknown>, {
85
160
  get(_target, prop) {
86
161
  if (prop === SCHEMA) return meta;
87
162
  if (typeof prop === "string") return (arg: unknown) => fieldCondition(prop, arg);
88
163
  return undefined;
89
164
  },
90
- }) as unknown as TableDef<N, C>;
165
+ }) as unknown as TableDef<N, C, PK>;
91
166
  }
92
167
 
93
168
  /** Read a table's metadata (columns / PK / name). */
@@ -100,13 +175,25 @@ export type SchemaOf<T extends readonly AnyTable[]> = {
100
175
  [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["columns"];
101
176
  };
102
177
 
178
+ /** name → its primary-key column union, derived from the tables array (the PK captured by the
179
+ * `primaryKey(...)` builder). Powers the mutator tx's typed `update`/`delete`/`row` pk args. */
180
+ export type PkMapOf<T extends readonly AnyTable[]> = {
181
+ [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["primaryKey"][number];
182
+ };
183
+
103
184
  /** name → columns, the resolved schema map carried in the {@link Schema} type. */
104
185
  export type ColsMap = Record<string, AnyCols>;
105
186
 
106
- export interface Schema<S extends ColsMap = ColsMap> {
187
+ /** name (some subset of its column names): the loose shape a {@link Schema}'s pk-map satisfies. The
188
+ * fallback when a schema wasn't built through {@link createSchema} — every column could be the pk. */
189
+ export type PkMap<S extends ColsMap> = { [N in keyof S]: keyof S[N] & string };
190
+
191
+ export interface Schema<S extends ColsMap = ColsMap, P extends Record<string, string> = PkMap<S>> {
107
192
  readonly tables: Readonly<Record<string, TableMeta>>;
108
193
  /** Phantom carrying name→columns for query-root inference (never read at runtime). */
109
194
  readonly __cols: S;
195
+ /** Phantom carrying name→pk-column-union for the typed mutator tx (never read at runtime). */
196
+ readonly __pk: P;
110
197
  }
111
198
 
112
199
  /** Table-name prefixes reserved by the engine for SYNTHETIC tables — `__agg_<fnv>` aggregate
@@ -125,13 +212,13 @@ export function isReservedTableName(name: string): boolean {
125
212
 
126
213
  export function createSchema<const T extends readonly AnyTable[]>(opts: {
127
214
  tables: T;
128
- }): Schema<SchemaOf<T>> {
215
+ }): Schema<SchemaOf<T>, PkMapOf<T>> {
129
216
  const tables: Record<string, TableMeta> = {};
130
217
  for (const t of opts.tables) {
131
218
  const m = t[SCHEMA];
132
219
  addTableMeta(tables, m, "createSchema");
133
220
  }
134
- return { tables } as Schema<SchemaOf<T>>;
221
+ return { tables } as unknown as Schema<SchemaOf<T>, PkMapOf<T>>;
135
222
  }
136
223
 
137
224
  /** Extend a generated/synced schema with client-authoritative local-only tables.
@@ -141,10 +228,10 @@ export function createSchema<const T extends readonly AnyTable[]>(opts: {
141
228
  * client. The added tables MUST be `table(name, { local: true })`: `extendSchema` deliberately
142
229
  * refuses to append ordinary synced tables, because those need to come from daemon introspection
143
230
  * (`rindle schema gen`) so the server and client cannot drift. */
144
- export function extendSchema<S extends ColsMap, const T extends readonly AnyTable[]>(
145
- base: Schema<S>,
231
+ export function extendSchema<S extends ColsMap, P extends Record<string, string>, const T extends readonly AnyTable[]>(
232
+ base: Schema<S, P>,
146
233
  opts: { tables: T },
147
- ): Schema<S & SchemaOf<T>> {
234
+ ): Schema<S & SchemaOf<T>, P & PkMapOf<T>> {
148
235
  const tables: Record<string, TableMeta> = { ...base.tables };
149
236
  for (const t of opts.tables) {
150
237
  const m = t[SCHEMA];
@@ -155,7 +242,7 @@ export function extendSchema<S extends ColsMap, const T extends readonly AnyTabl
155
242
  }
156
243
  addTableMeta(tables, m, "extendSchema");
157
244
  }
158
- return { tables } as Schema<S & SchemaOf<T>>;
245
+ return { tables } as unknown as Schema<S & SchemaOf<T>, P & PkMapOf<T>>;
159
246
  }
160
247
 
161
248
  function addTableMeta(tables: Record<string, TableMeta>, m: TableMeta, caller: "createSchema" | "extendSchema") {
@@ -175,6 +262,118 @@ function addTableMeta(tables: Record<string, TableMeta>, m: TableMeta, caller: "
175
262
  tables[m.name] = m;
176
263
  }
177
264
 
265
+ // ----------------------------- refinement (narrowing generated column types) ---------------------
266
+ //
267
+ // SQL carries a column's KIND (string/number/boolean/json) but not a refinement WITHIN a kind — the
268
+ // element type of `json<T>()`, or a string/number literal union. `refineTable` re-types those
269
+ // columns on a generated `TableDef` (so its field conditions, `rel(...)`s, and `Row<typeof t>` all
270
+ // narrow), and `refineSchema` swaps the refined defs into the generated schema (so query roots and
271
+ // result rows narrow too). Both are runtime-validated IDENTITIES: a refinement narrows the phantom
272
+ // TS type but can never change a column's runtime kind, so the def still matches the daemon's wire
273
+ // schema exactly. Keep refinements in a hand-written module (beside `extendSchema`'s local tables);
274
+ // `schema.gen.ts` stays a pure, wholesale-regenerated artifact.
275
+
276
+ /** Per-column narrowings for {@link refineTable}: each entry must keep the column's kind and narrow
277
+ * its TS type (`Col<T2>` with `T2 extends T`) — `json<Meta>()` on a json column, a literal-union
278
+ * `string<"a" | "b">()` on a string column. Kind changes are rejected (cross-kind at compile time,
279
+ * same-phantom kind flips like `string()` on a json column at runtime). */
280
+ export type ColRefinements<C extends AnyCols> = {
281
+ readonly [K in keyof C]?: Col<ColT<C[K]>>;
282
+ };
283
+
284
+ /** `C` with the columns named in `R` re-typed to their refined `Col`s. */
285
+ export type RefinedCols<C extends AnyCols, R extends ColRefinements<C>> = {
286
+ [K in keyof C]: K extends keyof R ? (R[K] extends Col<unknown> ? R[K] : C[K]) : C[K];
287
+ };
288
+
289
+ /** Narrow a generated table's column TYPES without touching its runtime shape.
290
+ *
291
+ * Returns the SAME def, re-typed (identity, after validating that every refined column exists and
292
+ * keeps its kind) — so conditions built from it, `rel(...)`s anchored on it, and `Row<typeof t>`
293
+ * all see the narrowed types. Pass the result to {@link refineSchema} so query roots narrow too. */
294
+ export function refineTable<N extends string, C extends AnyCols, R extends ColRefinements<C>>(
295
+ base: TableDef<N, C>,
296
+ cols: R,
297
+ ): TableDef<N, RefinedCols<C, R>> {
298
+ const meta = base[SCHEMA];
299
+ for (const [name, col] of Object.entries(cols) as [string, Col<unknown> | undefined][]) {
300
+ if (col === undefined) continue;
301
+ const baseCol = meta.columns[name];
302
+ if (baseCol === undefined) {
303
+ throw new Error(`refineTable: table "${meta.name}" has no column "${name}".`);
304
+ }
305
+ if (col.type !== baseCol.type) {
306
+ throw new Error(
307
+ `refineTable: column "${meta.name}.${name}" is ${baseCol.type}, not ${col.type} — a refinement ` +
308
+ `may only narrow the TS type WITHIN a column's kind, never change the kind itself.`,
309
+ );
310
+ }
311
+ }
312
+ return base as unknown as TableDef<N, RefinedCols<C, R>>;
313
+ }
314
+
315
+ /** A table def acceptable to {@link refineSchema} over `Schema<S>`: its NAME must be one of `S`'s
316
+ * tables (a def for an unknown table is a compile error at the call site). */
317
+ export type RefinableTable<S extends ColsMap> = {
318
+ readonly [SCHEMA]: TableMeta<Extract<keyof S, string>, AnyCols>;
319
+ };
320
+
321
+ /** `S` with each table named in `T` re-typed to that def's (refined) columns. (The inner
322
+ * `extends AnyCols` guard is how the checker proves the remapped `SchemaOf<T>[K]` is a column
323
+ * map while `T` is still generic; it always holds for a concrete `T`.) */
324
+ export type RefinedColsMap<S extends ColsMap, T extends readonly AnyTable[]> = {
325
+ [K in keyof S]: K extends keyof SchemaOf<T> ? (SchemaOf<T>[K] extends AnyCols ? SchemaOf<T>[K] : S[K]) : S[K];
326
+ };
327
+
328
+ /** Swap {@link refineTable}-narrowed table defs into a generated schema, re-typing those tables for
329
+ * everything downstream of the schema (`newQueryBuilder`/`queries` roots, store row types).
330
+ *
331
+ * Runtime-validated identity: each def must name a table already in the schema and match its
332
+ * runtime shape exactly (same columns, kinds, primary key, and locality) — refinement narrows TS
333
+ * types, never what's on the wire. Composes with {@link extendSchema} in either order. */
334
+ export function refineSchema<S extends ColsMap, P extends Record<string, string>, const T extends readonly RefinableTable<S>[]>(
335
+ base: Schema<S, P>,
336
+ opts: { tables: T },
337
+ ): Schema<RefinedColsMap<S, T>, P> {
338
+ const seen = new Set<string>();
339
+ for (const t of opts.tables) {
340
+ const m = t[SCHEMA];
341
+ const baseMeta = base.tables[m.name];
342
+ if (baseMeta === undefined) {
343
+ throw new Error(
344
+ `refineSchema: schema has no table "${m.name}" — refinement re-types existing tables only ` +
345
+ `(add local-only tables with extendSchema).`,
346
+ );
347
+ }
348
+ if (seen.has(m.name)) {
349
+ throw new Error(`refineSchema: table "${m.name}" is refined twice.`);
350
+ }
351
+ seen.add(m.name);
352
+ assertRefinementMatches(m, baseMeta);
353
+ }
354
+ return base as unknown as Schema<RefinedColsMap<S, T>, P>;
355
+ }
356
+
357
+ /** The refined def must be the same table the schema already carries — identical column set, kinds,
358
+ * primary key, and locality — so swapping its TYPE in cannot change any runtime behavior. */
359
+ function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {
360
+ const cols = Object.keys(m.columns);
361
+ const baseCols = Object.keys(baseMeta.columns);
362
+ const matches =
363
+ cols.length === baseCols.length &&
364
+ cols.every((c) => baseMeta.columns[c] !== undefined && m.columns[c].type === baseMeta.columns[c].type) &&
365
+ m.primaryKey.length === baseMeta.primaryKey.length &&
366
+ m.primaryKey.every((k, i) => baseMeta.primaryKey[i] === k) &&
367
+ (m.local === true) === (baseMeta.local === true);
368
+ if (!matches) {
369
+ throw new Error(
370
+ `refineSchema: table "${m.name}" does not match the schema's table of that name — pass the ` +
371
+ `output of refineTable over the SAME generated def (identical columns, kinds, primary key, ` +
372
+ `and locality).`,
373
+ );
374
+ }
375
+ }
376
+
178
377
  /** Whether `table` is a {@link TableMeta.local local-only} table in `schema` (an unknown table
179
378
  * reads as non-local). The single locality predicate the backends key off. */
180
379
  export function isLocalTable<S extends ColsMap>(schema: Schema<S>, table: string): boolean {
@@ -247,6 +446,43 @@ export function tableSpec(meta: TableMeta): { columns: string[]; primaryKey: num
247
446
  return { columns, primaryKey };
248
447
  }
249
448
 
449
+ /** A table's insert-completeness plan, derived once from its `Col` markers and shared by BOTH write
450
+ * funnels — the client `trackingTx` and the server `renderOp` — so their required-sets can't drift
451
+ * (design 206 §6.1/§6.2). A `NOT NULL` column is `required`; a nullable column (`.nullable()` set
452
+ * `Col.optional`) may be omitted and is filled with `null` (see {@link insertCell}). PK columns are
453
+ * never nullable (introspection forces them non-null), so they are always required. */
454
+ export interface InsertPlan {
455
+ /** Every column, in wire order. */
456
+ readonly columns: string[];
457
+ /** Columns that MUST be present on a full insert — the non-nullable ones. */
458
+ readonly required: string[];
459
+ /** The nullable (omittable-to-null) columns, for a fast membership test in the fill. */
460
+ readonly nullable: ReadonlySet<string>;
461
+ }
462
+
463
+ /** Derive a table's {@link InsertPlan} from its column markers. */
464
+ export function insertPlan(meta: TableMeta): InsertPlan {
465
+ const columns = Object.keys(meta.columns);
466
+ const nullable = new Set(columns.filter((c) => meta.columns[c].optional === true));
467
+ return { columns, required: columns.filter((c) => !nullable.has(c)), nullable };
468
+ }
469
+
470
+ /** The cell a full insert writes for column `c`: the given value, or `null` when a nullable column
471
+ * is omitted (design 206 §6.2). The caller's completeness check ({@link InsertPlan.required})
472
+ * guarantees a non-nullable column is present, so its omission never reaches here. */
473
+ export function insertCell(row: Record<string, WireValue>, c: string): WireValue {
474
+ return c in row ? row[c] : null;
475
+ }
476
+
477
+ /** Encode a keyed-row cell to its wire {@link WireValue} for a column KIND — the one place both write
478
+ * funnels stringify a `json<T>` object (the typed mutator surface / design 206 §7). An
479
+ * already-stringified json value (a `string`) or any non-json cell passes through unchanged, so a
480
+ * mutator may pass EITHER a parsed object OR a JSON string. Mirrors `store.positionalize`. */
481
+ export function toCell(v: WireValue | object, type: ColType): WireValue {
482
+ if (type === "json" && v !== null && typeof v === "object") return JSON.stringify(v);
483
+ return v as WireValue;
484
+ }
485
+
250
486
  /** The client's per-table flat schema (name + column order + PK indices), the shape a
251
487
  * normalized `hello` advertises (NORMALIZED-CHANGES-DESIGN.md §3). Used to validate a
252
488
  * server hello against the CLIENT's own typed schema so a column-order / PK skew is caught