@rindle/client 0.4.2 → 0.4.4

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.
@@ -4,6 +4,8 @@
4
4
  // client applies to its local engine. This lives in `@rindle/client` (the leaf both tiers
5
5
  // import); the SQL renderer that consumes it is server-only (`@rindle/api-server`).
6
6
 
7
+ import type { Ast } from "./ast.ts";
8
+ import type { ColsMap, InsertOf, PkColsOf, PkMap, PkOf, Schema, UpdateOf } from "./schema.ts";
7
9
  import type { WireValue } from "./types.ts";
8
10
 
9
11
  /** A keyed row: column name → cell. The ergonomic write shape (validated against the schema at
@@ -49,9 +51,13 @@ export interface ServerWriteTx {
49
51
  // GENERATOR that `yield`s effects instead of an async/sync function. A generator is neither sync nor
50
52
  // async — the tier's DRIVER decides — so ONE body runs synchronously against the browser wasm engine
51
53
  // AND against a live async Postgres transaction on the server. Writes are `yield`ed and pipelined by
52
- // the driver (never individually awaited by the body); a `yield tx.row(...)` is the one thing that
53
- // suspends, evaluating to the read row. `yield tx.all([...])` fans reads out (Promise.all on the
54
- // server, in-order on the client) — order-preserving on both tiers, so the body stays deterministic.
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).
55
61
 
56
62
  /** A point read a generator mutator yields; the driver resolves it and feeds the row back through
57
63
  * `gen.next(row)`. Read-your-writes on every tier: the client reads its local engine, and both
@@ -64,34 +70,68 @@ export type ReadEffect = { kind: "row"; table: string; pk: KeyedRow };
64
70
  * to `(KeyedRow | undefined)[]` at runtime (cast it — the generator's single next-type is a row). */
65
71
  export type BatchEffect = { kind: "all"; effects: readonly YieldEffect[] };
66
72
 
67
- /** Everything a generator mutator may `yield`: a write {@link MutationOp}, a {@link ReadEffect}, or a
68
- * {@link BatchEffect}. */
69
- export type YieldEffect = MutationOp | ReadEffect | BatchEffect;
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;
70
95
 
71
96
  /** The tier-AGNOSTIC effect factory a generator mutator writes against. Every method just BUILDS an
72
97
  * effect to `yield` — it performs no I/O and holds no state, so the single {@link isoTx} instance is
73
98
  * shared by every mutator on both tiers; only the driver differs. `insert`/`upsert`/`insertIgnore`
74
99
  * take a FULL row; `update`/`delete` take the pk columns (`update` adds the columns to change). */
75
- export interface IsoTx {
76
- insert(table: string, row: KeyedRow): MutationOp;
77
- update(table: string, row: KeyedRow): MutationOp;
78
- upsert(table: string, row: KeyedRow): MutationOp;
79
- insertIgnore(table: string, row: KeyedRow): MutationOp;
80
- delete(table: string, pk: KeyedRow): MutationOp;
81
- row(table: string, pk: KeyedRow): ReadEffect;
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;
82
118
  all(effects: readonly YieldEffect[]): BatchEffect;
83
119
  }
84
120
 
85
- /** The one shared effect factory (stateless — see {@link IsoTx}). */
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. */
86
125
  export const isoTx: IsoTx = {
87
- insert: (table, row) => ({ kind: "insert", table, row }),
88
- update: (table, row) => ({ kind: "update", table, row }),
89
- upsert: (table, row) => ({ kind: "upsert", table, row }),
90
- insertIgnore: (table, row) => ({ kind: "insertIgnore", table, row }),
91
- delete: (table, pk) => ({ kind: "delete", table, pk }),
92
- row: (table, pk) => ({ kind: "row", table, pk }),
93
- all: (effects) => ({ kind: "all", effects }),
94
- };
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;
95
135
 
96
136
  /** The minimal per-invocation context a shared mutator sees on BOTH tiers: the acting principal. The
97
137
  * server injects its AUTHENTICATED user; the client injects its local user. (The server's own
@@ -142,6 +182,34 @@ export function shared<Args, Ctx extends MutatorCtx = MutatorCtx>(
142
182
  return Object.assign(run, { args });
143
183
  }
144
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
+
145
213
  /** True iff `fn` is a generator function (a shared/isomorphic mutator) rather than a plain function —
146
214
  * the driver accepts both forms. Detected structurally (native `GeneratorFunction`). */
147
215
  export function isGeneratorMutator(fn: unknown): fn is (...args: never[]) => MutationGen {
@@ -156,13 +224,16 @@ export function isGeneratorMutator(fn: unknown): fn is (...args: never[]) => Mut
156
224
  export interface SyncEffectExec {
157
225
  apply(op: MutationOp): void;
158
226
  read(table: string, pk: KeyedRow): KeyedRow | undefined;
227
+ query(q: QueryArg): QueryResultRow[];
159
228
  }
160
229
 
161
230
  /** Drive a generator mutator SYNCHRONOUSLY (the client, inside the wasm write transaction). Each
162
- * yielded write applies immediately; each read is resolved and fed back; a batch runs in order. */
231
+ * yielded write applies immediately; each read (point or query) is resolved and fed back; a batch
232
+ * runs in order. */
163
233
  export function driveMutationSync(gen: MutationGen, exec: SyncEffectExec): void {
164
234
  const run = (eff: YieldEffect): KeyedRow | undefined => {
165
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;
166
237
  if (eff.kind === "all") return eff.effects.map(run) as unknown as KeyedRow | undefined;
167
238
  exec.apply(eff);
168
239
  return undefined;
@@ -174,14 +245,17 @@ export function driveMutationSync(gen: MutationGen, exec: SyncEffectExec): void
174
245
  export interface AsyncEffectExec {
175
246
  apply(op: MutationOp): Promise<void>;
176
247
  read(table: string, pk: KeyedRow): Promise<KeyedRow | undefined>;
248
+ query(q: QueryArg): Promise<QueryResultRow[]>;
177
249
  }
178
250
 
179
251
  /** Drive a generator mutator ASYNCHRONOUSLY (the server, against the open transaction). Writes are
180
252
  * awaited (harmless: a single interactive Postgres connection serializes statements anyway, and the
181
- * daemon backend resolves them instantly), reads suspend, and a batch fans out with `Promise.all`. */
253
+ * daemon backend resolves them instantly), reads (point or query) suspend, and a batch fans out with
254
+ * `Promise.all`. */
182
255
  export async function driveMutationAsync(gen: MutationGen, exec: AsyncEffectExec): Promise<void> {
183
256
  const run = async (eff: YieldEffect): Promise<KeyedRow | undefined> => {
184
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;
185
259
  if (eff.kind === "all") return (await Promise.all(eff.effects.map(run))) as unknown as KeyedRow | undefined;
186
260
  await exec.apply(eff);
187
261
  return undefined;
package/src/schema.ts CHANGED
@@ -8,13 +8,19 @@
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
13
  /** A column descriptor. `type` drives the comparator + JSON parsing; `__t` is a phantom. A
14
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. */
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. */
16
21
  export interface Col<T> {
17
22
  readonly type: ColType;
23
+ readonly optional?: boolean;
18
24
  readonly __t?: T;
19
25
  }
20
26
  export type ColT<X> = X extends Col<infer T> ? T : never;
@@ -24,13 +30,18 @@ export type RowOf<C extends AnyCols> = { [K in keyof C]: ColT<C[K]> };
24
30
  /** The chainable form returned by the column factories: a {@link Col} plus `.nullable()`.
25
31
  *
26
32
  * `.nullable()` widens the column's value type to `T | null` — its `Row<…>` field becomes
27
- * `T | null`, and (design 206 Phase 2) it may be omitted from an insert. `rindle schema gen` emits
28
- * `.nullable()` for every nullable (non-`NOT NULL`) SQL column; you can also call it by hand on a
29
- * local-only table's columns. It is idempotent and stays chainable. */
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. */
30
37
  export interface ColBuilder<T> extends Col<T> {
31
38
  nullable(): ColBuilder<T | null>;
32
39
  }
33
- const makeCol = <T>(type: ColType): ColBuilder<T> => ({ type, nullable: () => makeCol<T | null>(type) });
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
+ });
34
45
 
35
46
  export const string = <T extends string = string>(): ColBuilder<T> => makeCol<T>("string");
36
47
  export const number = <T extends number = number>(): ColBuilder<T> => makeCol<T>("number");
@@ -40,30 +51,41 @@ export const json = <T = unknown>(): ColBuilder<T> => makeCol<T>("json");
40
51
  /** Metadata key on a {@link TableDef} (a `unique symbol`, so no column name collides). */
41
52
  export const SCHEMA: unique symbol = Symbol("rindle.schema");
42
53
 
43
- 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> {
44
55
  readonly name: N;
45
56
  readonly columns: C;
46
- // Plain `string[]` (NOT `keyof C`): `keyof C` is contravariant, which would make
47
- // `TableMeta` non-covariant in `C` and break `TableDef<concrete>` → `AnyTable`. Key
48
- // validity is enforced on the `primaryKey()` builder method (`K extends keyof C`).
49
- 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[];
50
63
  /** A **local-only** table (`201-LOCAL-ONLY-TABLES-DESIGN.md`): client-authoritative,
51
64
  * never synced/tracked/rebased. The single marker the whole design keys off (§4) — it is
52
65
  * immutable for the table's lifetime (N2) and never crosses the wire (C2). Absent ⇒ an
53
- * ordinary synced table. */
54
- readonly local?: boolean;
66
+ * ordinary synced table.
67
+ *
68
+ * `true` ⇒ eligible for the local-persistence plane (durable + cross-tab when the client
69
+ * enables `persistLocal`, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md`). `"session"` ⇒ local but
70
+ * EPHEMERAL: outside the plane entirely — never persisted, never replicated across tabs,
71
+ * per-client-instance state that empties on reload (201's original behavior) even when
72
+ * `persistLocal` is on. Every OTHER locality rule (untracked source, M1/M2 guards, E3/Q1
73
+ * wire exclusion) treats both variants identically. */
74
+ readonly local?: boolean | "session";
55
75
  }
56
76
 
57
77
  /** Options for {@link table}. */
58
78
  export interface TableOptions {
59
79
  /** Declare a {@link TableMeta.local local-only} table (selection state, draft text, view
60
80
  * prefs, scratch rows): client-authoritative, never synced or rebased. See
61
- * `201-LOCAL-ONLY-TABLES-DESIGN.md`. */
62
- local?: boolean;
81
+ * `201-LOCAL-ONLY-TABLES-DESIGN.md`. Pass `"session"` for a local table that must stay
82
+ * EPHEMERAL and per-tab even when the client enables `persistLocal` — e.g. selection state
83
+ * that should not follow the user across tabs or reloads (207 §5.4). */
84
+ local?: boolean | "session";
63
85
  }
64
86
 
65
- export type TableDef<N extends string, C extends AnyCols> = {
66
- readonly [SCHEMA]: TableMeta<N, C>;
87
+ export type TableDef<N extends string, C extends AnyCols, PK extends string = string> = {
88
+ readonly [SCHEMA]: TableMeta<N, C, PK>;
67
89
  } & {
68
90
  readonly [K in keyof C]: (arg: Arg<ColT<C[K]>>) => Cond<RowOf<C>>;
69
91
  };
@@ -79,28 +101,77 @@ export type AnyTable = TableLike<AnyCols>;
79
101
  * the schema instead of hand-maintaining a parallel twin. */
80
102
  export type Row<T extends AnyTable> = RowOf<T[typeof SCHEMA]["columns"]>;
81
103
 
104
+ /** Flatten an intersection of mapped types into a single object type (preserving `?`/`readonly`) so
105
+ * {@link InsertOf} reads as one clean shape in editor hovers, not `A & B`. */
106
+ type Simplify<T> = { [K in keyof T]: T[K] };
107
+
108
+ /** True for the top types `unknown`/`any` (`unknown extends any` too), where `[null] extends [T]` is
109
+ * vacuously true and so can't tell a `.nullable()` apart. */
110
+ type IsTop<T> = unknown extends T ? true : false;
111
+
112
+ /** Whether column `X` may be OMITTED from an insert: it admits `null` at the type level — EXCLUDING
113
+ * the top types. A bare `json()` is `json<unknown>()`, and `unknown | null` collapses back to
114
+ * `unknown`, erasing whether `.nullable()` was applied; treating it as required is the safe
115
+ * direction (a `NOT NULL` json column is never wrongly made optional). Declare `json<T>()` to make a
116
+ * nullable json column omittable. The runtime companion is `Col.optional` (design 206 §6.2/§7). */
117
+ type InsertOptional<X> = IsTop<ColT<X>> extends true ? false : [null] extends [ColT<X>] ? true : false;
118
+
119
+ /** The INSERT shape of a column map: {@link RowOf} with every NULLABLE column made OPTIONAL (`?`) —
120
+ * it may be omitted and is filled with `null` by both write funnels (design 206 §6.2/§7). `NOT NULL`
121
+ * columns stay required. The insert-side twin of {@link RowOf}, mirroring Drizzle's `$inferInsert`
122
+ * vs `$inferSelect` split. */
123
+ export type InsertOf<C extends AnyCols> = Simplify<
124
+ { [K in keyof C as InsertOptional<C[K]> extends true ? never : K]: ColT<C[K]> } & {
125
+ [K in keyof C as InsertOptional<C[K]> extends true ? K : never]?: ColT<C[K]>;
126
+ }
127
+ >;
128
+
129
+ /** The insert type of a table def: `Insert<typeof issue>` → `{ id: string; … assignee?: string | null }`.
130
+ * The whole-table ergonomic form of {@link InsertOf} (the insert-side twin of {@link Row}). */
131
+ export type Insert<T extends AnyTable> = InsertOf<T[typeof SCHEMA]["columns"]>;
132
+
133
+ /** The exact PRIMARY-KEY columns of a table (each typed), required — the identity a `delete`/`row`,
134
+ * and the WHERE half of an `update`, take. `PK` is the table's pk-column union (the schema's `__pk`). */
135
+ export type PkOf<C extends AnyCols, PK extends string> = { [K in PK & keyof C]: ColT<C[K & keyof C]> };
136
+
137
+ /** The UPDATE shape of a table: its {@link PkOf primary-key columns} REQUIRED (they identify the row)
138
+ * plus every non-pk column OPTIONAL (`?`) and typed — a nullable one stays `T | null` (settable to
139
+ * null), a `NOT NULL` one is `T`. Omitted non-pk columns are left unchanged. */
140
+ export type UpdateOf<C extends AnyCols, PK extends string> = Simplify<
141
+ PkOf<C, PK> & { [K in Exclude<keyof C, PK>]?: ColT<C[K]> }
142
+ >;
143
+
144
+ /** The primary-key column union for table `N` of a schema, read from its `__pk` map `P` and narrowed
145
+ * to `N`'s actual columns (falling back to all columns when `P` doesn't name `N` — e.g. the loose
146
+ * default schema). Feeds {@link PkOf}/{@link UpdateOf} in the typed mutator tx. */
147
+ export type PkColsOf<S extends ColsMap, P extends Record<string, string>, N extends keyof S> = (N extends keyof P
148
+ ? P[N]
149
+ : keyof S[N] & string) &
150
+ keyof S[N] &
151
+ string;
152
+
82
153
  /** `table("issue").columns({ id: string(), … }).primaryKey("id")`. Pass `{ local: true }` for a
83
154
  * {@link TableMeta.local local-only} table (`201-LOCAL-ONLY-TABLES-DESIGN.md`). */
84
155
  export function table<N extends string>(name: N, opts?: TableOptions) {
85
156
  return {
86
157
  columns<C extends AnyCols>(cols: C) {
87
158
  return {
88
- primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C> {
89
- return makeTableDef<N, C>({ name, columns: cols, primaryKey: keys, local: opts?.local });
159
+ primaryKey<K extends keyof C & string>(...keys: K[]): TableDef<N, C, K> {
160
+ return makeTableDef<N, C, K>({ name, columns: cols, primaryKey: keys, local: opts?.local });
90
161
  },
91
162
  };
92
163
  },
93
164
  };
94
165
  }
95
166
 
96
- function makeTableDef<N extends string, C extends AnyCols>(meta: TableMeta<N, C>): TableDef<N, C> {
167
+ function makeTableDef<N extends string, C extends AnyCols, PK extends string>(meta: TableMeta<N, C, PK>): TableDef<N, C, PK> {
97
168
  return new Proxy({} as Record<string | symbol, unknown>, {
98
169
  get(_target, prop) {
99
170
  if (prop === SCHEMA) return meta;
100
171
  if (typeof prop === "string") return (arg: unknown) => fieldCondition(prop, arg);
101
172
  return undefined;
102
173
  },
103
- }) as unknown as TableDef<N, C>;
174
+ }) as unknown as TableDef<N, C, PK>;
104
175
  }
105
176
 
106
177
  /** Read a table's metadata (columns / PK / name). */
@@ -113,13 +184,25 @@ export type SchemaOf<T extends readonly AnyTable[]> = {
113
184
  [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["columns"];
114
185
  };
115
186
 
187
+ /** name → its primary-key column union, derived from the tables array (the PK captured by the
188
+ * `primaryKey(...)` builder). Powers the mutator tx's typed `update`/`delete`/`row` pk args. */
189
+ export type PkMapOf<T extends readonly AnyTable[]> = {
190
+ [E in T[number] as E[typeof SCHEMA]["name"]]: E[typeof SCHEMA]["primaryKey"][number];
191
+ };
192
+
116
193
  /** name → columns, the resolved schema map carried in the {@link Schema} type. */
117
194
  export type ColsMap = Record<string, AnyCols>;
118
195
 
119
- export interface Schema<S extends ColsMap = ColsMap> {
196
+ /** name (some subset of its column names): the loose shape a {@link Schema}'s pk-map satisfies. The
197
+ * fallback when a schema wasn't built through {@link createSchema} — every column could be the pk. */
198
+ export type PkMap<S extends ColsMap> = { [N in keyof S]: keyof S[N] & string };
199
+
200
+ export interface Schema<S extends ColsMap = ColsMap, P extends Record<string, string> = PkMap<S>> {
120
201
  readonly tables: Readonly<Record<string, TableMeta>>;
121
202
  /** Phantom carrying name→columns for query-root inference (never read at runtime). */
122
203
  readonly __cols: S;
204
+ /** Phantom carrying name→pk-column-union for the typed mutator tx (never read at runtime). */
205
+ readonly __pk: P;
123
206
  }
124
207
 
125
208
  /** Table-name prefixes reserved by the engine for SYNTHETIC tables — `__agg_<fnv>` aggregate
@@ -138,13 +221,13 @@ export function isReservedTableName(name: string): boolean {
138
221
 
139
222
  export function createSchema<const T extends readonly AnyTable[]>(opts: {
140
223
  tables: T;
141
- }): Schema<SchemaOf<T>> {
224
+ }): Schema<SchemaOf<T>, PkMapOf<T>> {
142
225
  const tables: Record<string, TableMeta> = {};
143
226
  for (const t of opts.tables) {
144
227
  const m = t[SCHEMA];
145
228
  addTableMeta(tables, m, "createSchema");
146
229
  }
147
- return { tables } as Schema<SchemaOf<T>>;
230
+ return { tables } as unknown as Schema<SchemaOf<T>, PkMapOf<T>>;
148
231
  }
149
232
 
150
233
  /** Extend a generated/synced schema with client-authoritative local-only tables.
@@ -154,21 +237,21 @@ export function createSchema<const T extends readonly AnyTable[]>(opts: {
154
237
  * client. The added tables MUST be `table(name, { local: true })`: `extendSchema` deliberately
155
238
  * refuses to append ordinary synced tables, because those need to come from daemon introspection
156
239
  * (`rindle schema gen`) so the server and client cannot drift. */
157
- export function extendSchema<S extends ColsMap, const T extends readonly AnyTable[]>(
158
- base: Schema<S>,
240
+ export function extendSchema<S extends ColsMap, P extends Record<string, string>, const T extends readonly AnyTable[]>(
241
+ base: Schema<S, P>,
159
242
  opts: { tables: T },
160
- ): Schema<S & SchemaOf<T>> {
243
+ ): Schema<S & SchemaOf<T>, P & PkMapOf<T>> {
161
244
  const tables: Record<string, TableMeta> = { ...base.tables };
162
245
  for (const t of opts.tables) {
163
246
  const m = t[SCHEMA];
164
- if (m.local !== true) {
247
+ if (!m.local) {
165
248
  throw new Error(
166
249
  `extendSchema: table "${m.name}" is not local-only — synced tables must be generated from the daemon schema.`,
167
250
  );
168
251
  }
169
252
  addTableMeta(tables, m, "extendSchema");
170
253
  }
171
- return { tables } as Schema<S & SchemaOf<T>>;
254
+ return { tables } as unknown as Schema<S & SchemaOf<T>, P & PkMapOf<T>>;
172
255
  }
173
256
 
174
257
  function addTableMeta(tables: Record<string, TableMeta>, m: TableMeta, caller: "createSchema" | "extendSchema") {
@@ -257,10 +340,10 @@ export type RefinedColsMap<S extends ColsMap, T extends readonly AnyTable[]> = {
257
340
  * Runtime-validated identity: each def must name a table already in the schema and match its
258
341
  * runtime shape exactly (same columns, kinds, primary key, and locality) — refinement narrows TS
259
342
  * types, never what's on the wire. Composes with {@link extendSchema} in either order. */
260
- export function refineSchema<S extends ColsMap, const T extends readonly RefinableTable<S>[]>(
261
- base: Schema<S>,
343
+ export function refineSchema<S extends ColsMap, P extends Record<string, string>, const T extends readonly RefinableTable<S>[]>(
344
+ base: Schema<S, P>,
262
345
  opts: { tables: T },
263
- ): Schema<RefinedColsMap<S, T>> {
346
+ ): Schema<RefinedColsMap<S, T>, P> {
264
347
  const seen = new Set<string>();
265
348
  for (const t of opts.tables) {
266
349
  const m = t[SCHEMA];
@@ -277,7 +360,7 @@ export function refineSchema<S extends ColsMap, const T extends readonly Refinab
277
360
  seen.add(m.name);
278
361
  assertRefinementMatches(m, baseMeta);
279
362
  }
280
- return base as unknown as Schema<RefinedColsMap<S, T>>;
363
+ return base as unknown as Schema<RefinedColsMap<S, T>, P>;
281
364
  }
282
365
 
283
366
  /** The refined def must be the same table the schema already carries — identical column set, kinds,
@@ -290,7 +373,9 @@ function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {
290
373
  cols.every((c) => baseMeta.columns[c] !== undefined && m.columns[c].type === baseMeta.columns[c].type) &&
291
374
  m.primaryKey.length === baseMeta.primaryKey.length &&
292
375
  m.primaryKey.every((k, i) => baseMeta.primaryKey[i] === k) &&
293
- (m.local === true) === (baseMeta.local === true);
376
+ // Locality must match EXACTLY, including the persisted-vs-session variant (a refinement
377
+ // flipping `true` ↔ `"session"` would silently change the table's durability).
378
+ (m.local ?? false) === (baseMeta.local ?? false);
294
379
  if (!matches) {
295
380
  throw new Error(
296
381
  `refineSchema: table "${m.name}" does not match the schema's table of that name — pass the ` +
@@ -301,16 +386,46 @@ function assertRefinementMatches(m: TableMeta, baseMeta: TableMeta): void {
301
386
  }
302
387
 
303
388
  /** Whether `table` is a {@link TableMeta.local local-only} table in `schema` (an unknown table
304
- * reads as non-local). The single locality predicate the backends key off. */
389
+ * reads as non-local). The single locality predicate the backends key off. BOTH variants —
390
+ * `true` and `"session"` — are local here; the persisted/ephemeral split matters only to the
391
+ * persistence plane ({@link persistedLocalTableNames}). */
305
392
  export function isLocalTable<S extends ColsMap>(schema: Schema<S>, table: string): boolean {
306
- return schema.tables[table]?.local === true;
393
+ return Boolean(schema.tables[table]?.local);
307
394
  }
308
395
 
309
- /** The set of local-only table names in `schema` (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4). */
396
+ /** The set of local-only table names in `schema` (`201-LOCAL-ONLY-TABLES-DESIGN.md` §4) — BOTH
397
+ * variants (`true` and `"session"`); every locality rule except persistence keys off this set. */
310
398
  export function localTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {
311
399
  return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local));
312
400
  }
313
401
 
402
+ /** The subset of {@link localTableNames} eligible for the persistence plane — `local: true` only.
403
+ * A `local: "session"` table stays outside it: never persisted, never replicated across tabs
404
+ * (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.4). */
405
+ export function persistedLocalTableNames<S extends ColsMap>(schema: Schema<S>): Set<string> {
406
+ return new Set(Object.keys(schema.tables).filter((n) => schema.tables[n].local === true));
407
+ }
408
+
409
+ /** A stable fingerprint of the schema's PERSISTED local tables only — the persistence gate's
410
+ * `schemaHash` (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §3.3 / P7). Per `local: true` table:
411
+ * `(name, ordered column names + types + optionality, pk columns)`. Column order is kept (rows are
412
+ * positional); tables are sorted by name so registration order can't skew it; synced-table AND
413
+ * `local: "session"` changes never move it (reshaping an ephemeral table must not wipe durable
414
+ * data). The value is the canonical descriptor itself, not a digest — local-table sets are tiny,
415
+ * and exactness (no collision can ever skip a P7 clear) beats compactness here. */
416
+ export function localSchemaHash<S extends ColsMap>(schema: Schema<S>): string {
417
+ // Derived from persistedLocalTableNames — the hash's contract is "fingerprints exactly the
418
+ // tables the plane persists", so the two must share one predicate, not two copies of it.
419
+ const tables = [...persistedLocalTableNames(schema)]
420
+ .sort()
421
+ .map((n) => {
422
+ const meta = schema.tables[n];
423
+ const cols = Object.keys(meta.columns).map((c) => [c, meta.columns[c].type, meta.columns[c].optional === true]);
424
+ return [n, cols, meta.primaryKey];
425
+ });
426
+ return `v1:${JSON.stringify(tables)}`;
427
+ }
428
+
314
429
  // ----------------------------- relationships (FRAGMENT-COMPOSITION-DESIGN §4.2, named edges) -----
315
430
  //
316
431
  // A relationship is the correlation (`parent.col → child.col`) declared ONCE as a value, so `sub`,
@@ -372,6 +487,43 @@ export function tableSpec(meta: TableMeta): { columns: string[]; primaryKey: num
372
487
  return { columns, primaryKey };
373
488
  }
374
489
 
490
+ /** A table's insert-completeness plan, derived once from its `Col` markers and shared by BOTH write
491
+ * funnels — the client `trackingTx` and the server `renderOp` — so their required-sets can't drift
492
+ * (design 206 §6.1/§6.2). A `NOT NULL` column is `required`; a nullable column (`.nullable()` set
493
+ * `Col.optional`) may be omitted and is filled with `null` (see {@link insertCell}). PK columns are
494
+ * never nullable (introspection forces them non-null), so they are always required. */
495
+ export interface InsertPlan {
496
+ /** Every column, in wire order. */
497
+ readonly columns: string[];
498
+ /** Columns that MUST be present on a full insert — the non-nullable ones. */
499
+ readonly required: string[];
500
+ /** The nullable (omittable-to-null) columns, for a fast membership test in the fill. */
501
+ readonly nullable: ReadonlySet<string>;
502
+ }
503
+
504
+ /** Derive a table's {@link InsertPlan} from its column markers. */
505
+ export function insertPlan(meta: TableMeta): InsertPlan {
506
+ const columns = Object.keys(meta.columns);
507
+ const nullable = new Set(columns.filter((c) => meta.columns[c].optional === true));
508
+ return { columns, required: columns.filter((c) => !nullable.has(c)), nullable };
509
+ }
510
+
511
+ /** The cell a full insert writes for column `c`: the given value, or `null` when a nullable column
512
+ * is omitted (design 206 §6.2). The caller's completeness check ({@link InsertPlan.required})
513
+ * guarantees a non-nullable column is present, so its omission never reaches here. */
514
+ export function insertCell(row: Record<string, WireValue>, c: string): WireValue {
515
+ return c in row ? row[c] : null;
516
+ }
517
+
518
+ /** Encode a keyed-row cell to its wire {@link WireValue} for a column KIND — the one place both write
519
+ * funnels stringify a `json<T>` object (the typed mutator surface / design 206 §7). An
520
+ * already-stringified json value (a `string`) or any non-json cell passes through unchanged, so a
521
+ * mutator may pass EITHER a parsed object OR a JSON string. Mirrors `store.positionalize`. */
522
+ export function toCell(v: WireValue | object, type: ColType): WireValue {
523
+ if (type === "json" && v !== null && typeof v === "object") return JSON.stringify(v);
524
+ return v as WireValue;
525
+ }
526
+
375
527
  /** The client's per-table flat schema (name + column order + PK indices), the shape a
376
528
  * normalized `hello` advertises (NORMALIZED-CHANGES-DESIGN.md §3). Used to validate a
377
529
  * server hello against the CLIENT's own typed schema so a column-order / PK skew is caught