@rindle/api-server 0.2.0 → 0.4.2

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.
package/src/index.ts CHANGED
@@ -1,5 +1,25 @@
1
- import type { Ast, MutationEnvelope, NamedQuery, Query } from "@rindle/client";
1
+ import { driveMutationAsync, isoTx } from "@rindle/client";
2
2
  import type {
3
+ Ast,
4
+ ColType,
5
+ Condition,
6
+ KeyedRow,
7
+ MutationEnvelope,
8
+ MutationOp,
9
+ MutatorCtx,
10
+ NamedQuery,
11
+ Query,
12
+ Schema,
13
+ SharedMutator,
14
+ SharedMutatorWithArgs,
15
+ ServerWriteTx,
16
+ } from "@rindle/client";
17
+ import { DaemonHttpError } from "@rindle/daemon-client";
18
+ import { compile as compileQueryAst } from "@rindle/query-compiler";
19
+ import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindle/query-compiler";
20
+ import type {
21
+ ClaimRoomEpochInput,
22
+ ClaimRoomEpochOutput,
3
23
  DematerializeInput,
4
24
  DematerializeOutput,
5
25
  MaterializationPolicy,
@@ -9,9 +29,16 @@ import type {
9
29
  MigrateOutput,
10
30
  MutationRejection,
11
31
  MutationRejectionOutput,
32
+ MutationSessionBegin,
33
+ MutationSessionBeginOutput,
34
+ MutationSessionExec,
35
+ MutationSessionQuery,
36
+ MutationSessionRef,
12
37
  QueryOnceInput,
13
38
  QueryOnceOutput,
14
39
  RindleDaemonClient,
40
+ RoomLmidsInput,
41
+ RoomLmidsOutput,
15
42
  RowChangeTxn,
16
43
  RowChangeTxnOutput,
17
44
  SqlRead,
@@ -23,10 +50,21 @@ import type {
23
50
  WireValue,
24
51
  } from "@rindle/daemon-client";
25
52
 
53
+ // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
54
+ // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
55
+ // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
56
+ export { isoTx, shared } from "@rindle/client";
57
+ export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
58
+
26
59
  export const DEFAULT_RINDLE_API_ROUTES = {
27
60
  query: "/api/rindle/query",
28
61
  read: "/api/rindle/read",
29
62
  mutate: "/api/rindle/mutate",
63
+ // The room write-authority host (RINDLE-REALTIME-DESIGN.md §5.3.1): the room's
64
+ // SOLE flush counterpart — the store's own handler is private ingress behind these.
65
+ applyRowChangeTxn: "/api/rindle/apply-row-change-txn",
66
+ claimRoomEpoch: "/api/rindle/claim-room-epoch",
67
+ roomLmids: "/api/rindle/room-lmids",
30
68
  } as const;
31
69
 
32
70
  export type MaybePromise<T> = T | PromiseLike<T>;
@@ -72,14 +110,39 @@ export interface MutationContext<User> {
72
110
  request?: unknown;
73
111
  }
74
112
 
113
+ /** The legacy raw-SQL escape hatch (still the surface for relational/authority statements a keyed op
114
+ * can't express — an owner-gated cascade, a `NOT EXISTS` dedup). `exec` accumulates on the daemon
115
+ * backend and executes live on the Postgres backend; `statements` is the collected raw list. */
75
116
  export interface SqlMutationTx {
76
117
  exec(sql: string, params?: WireValue[]): void;
77
118
  readonly statements: readonly SqlStatement[];
78
119
  }
79
120
 
121
+ /** The write handle a server mutator runs against — the ASYNC twin of the client's `MutationTx`. It
122
+ * is both the isomorphic {@link ServerWriteTx} logical surface (insert/update/upsert/insertIgnore/
123
+ * delete/row, rendered to dialect SQL) AND the legacy {@link SqlMutationTx} raw escape hatch. Both
124
+ * backends run reads through the OPEN transaction (read-your-writes): the Postgres backend executes
125
+ * everything live; the daemon backend accumulates writes and lazily upgrades to an interactive
126
+ * mutation session at the first read (DAEMON-INTERACTIVE-TXN-DESIGN.md §5). */
127
+ export interface ServerMutationTx extends ServerWriteTx, SqlMutationTx {
128
+ /** Run a full query (a fluent `Query` or its wire `Ast`) INSIDE the open transaction —
129
+ * read-your-writes, like {@link ServerWriteTx.row} but for arbitrary shapes. Returns the
130
+ * parsed nested result tree: an array for a plural root, an object or `null` for a `.one()`
131
+ * root, with cells in their raw SQLite storage-class representations (the same vocabulary
132
+ * `row` speaks). Daemon backend: compiled by `@rindle/query-compiler`'s sqlite dialect (bind
133
+ * params, NO casts — §5.4) and executed through the mutation session. Postgres backend: lands
134
+ * with the read-compiler catalog integration (POSTGRES-READ-COMPILER-DESIGN.md Phase B). */
135
+ query(q: Ast | Query<any, any, any>): Promise<unknown>;
136
+ }
137
+
80
138
  export type ApiMutatorResult = void | SqlStatement[] | SqlTxn;
139
+ /** A server mutator: a plain async function against the live {@link ServerMutationTx}. Two ways to
140
+ * write one (MUTATORS-ISOMORPHIC): drive it directly (the raw escape hatch — an owner-gated cascade,
141
+ * a `NOT EXISTS` dedup — plus a returned `SqlStatement[]`/`SqlTxn`), OR delegate to a SHARED generator
142
+ * (the SAME body the client predicts) via {@link runSharedMutation}, keeping only the server-only
143
+ * authority (arg parse, principal, policy) in the wrapper. */
81
144
  export type ApiMutator<User, Args> = (
82
- tx: SqlMutationTx,
145
+ tx: ServerMutationTx,
83
146
  args: Args,
84
147
  ctx: MutationContext<User>,
85
148
  ) => MaybePromise<ApiMutatorResult>;
@@ -130,6 +193,47 @@ export interface QueryReadResponse {
130
193
  wsEndpoint?: string;
131
194
  }
132
195
 
196
+ /** The context a {@link MutationBackend} needs to run one mutation inside its transaction. */
197
+ export interface MutationRunInput {
198
+ envelope: MutationEnvelope;
199
+ /** Schema-derived render metadata (from {@link RindleApiServerOptions.schema}). A logical op on a
200
+ * table absent here throws loudly — never a silent dropped write. `{}` when no schema is set. */
201
+ render: RenderIndex;
202
+ /** Invoke the (authorized) mutator against the backend-provided tx. A THROW that is NOT a
203
+ * {@link BackendError} is a BUSINESS rejection (roll the data back, then advance `lmid` alone,
204
+ * §2.4); a {@link BackendError} (a DB-layer failure) is INFRA and rejects the returned promise. */
205
+ run(tx: ServerMutationTx): Promise<void>;
206
+ }
207
+
208
+ /** The result of {@link MutationBackend.runMutation}: either the data+lmid committed together, or a
209
+ * business rejection whose data was rolled back but whose `lmid` still advanced (§2.4). */
210
+ export type MutationOutcome =
211
+ | { accepted: true; output: SqlTxnOutput }
212
+ | { accepted: false; reason: string; output?: unknown };
213
+
214
+ /**
215
+ * Where a mutation runs and who stamps `lmid` — the seam that makes the mutator authoring surface
216
+ * backend-agnostic (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6; MUTATORS-ISOMORPHIC plan). Two impls
217
+ * ship: {@link daemonBackend} (the default — writes accumulate, one batch to the daemon which stamps
218
+ * `lmid` co-transactionally) and {@link postgresBackend} (a REAL interactive PG transaction — writes
219
+ * and reads execute live, `lmid` committed in the same txn, confirmation riding the CDC loop down).
220
+ *
221
+ * The load-bearing invariant is that a mutation ALWAYS advances the client's `last_mutation_id`:
222
+ * - `runMutation` runs the mutator inside the backend's transaction; on success it advances `lmid`
223
+ * to `envelope.mid` in the SAME atomic unit (OPTIMISTIC-WRITES-DESIGN.md §8.2); on a BUSINESS
224
+ * rejection it rolls the data back but STILL advances `lmid` (§2.4 — else the client's pending
225
+ * queue never drains and the optimistic stack wedges).
226
+ * - `reject` is the pre-flight path (unknown mutator, failed authorization): NO data, `lmid` alone.
227
+ * - An infrastructure failure THROWS from `runMutation` — never a user rejection (the client
228
+ * retries; mid dedup absorbs any applied prefix).
229
+ */
230
+ export interface MutationBackend {
231
+ /** The SQL dialect this backend renders logical ops to (drives placeholder style). */
232
+ readonly dialect: SqlDialect;
233
+ runMutation(input: MutationRunInput): Promise<MutationOutcome>;
234
+ reject(input: { envelope: MutationEnvelope; reason: string }): Promise<unknown>;
235
+ }
236
+
133
237
  export interface PushMutationRequest<User> {
134
238
  user: User;
135
239
  envelope: MutationEnvelope;
@@ -150,6 +254,18 @@ export interface RindleApiRoutes {
150
254
  query: string;
151
255
  read: string;
152
256
  mutate: string;
257
+ applyRowChangeTxn: string;
258
+ claimRoomEpoch: string;
259
+ roomLmids: string;
260
+ }
261
+
262
+ /** A room-host reply the transport writes VERBATIM (`status` + JSON `body`): the
263
+ * daemon's fence/conflict/identity semantics ride specific statuses and body shapes
264
+ * the room's `httpAuthority` decodes, so this endpoint trio can't run through the
265
+ * throw-on-error result shapes the viewer endpoints use. */
266
+ export interface RoomHostResponse {
267
+ status: number;
268
+ body: unknown;
153
269
  }
154
270
 
155
271
  /** A named query to keep permanently materialized (warm with zero subscribers). */
@@ -168,11 +284,25 @@ export interface PinFanout {
168
284
 
169
285
  export interface RindleApiServerOptions<User> {
170
286
  daemon: RindleDaemonClient;
287
+ /** Where mutations are applied and `lmid` is stamped ({@link MutationBackend}). Default:
288
+ * `daemonBackend(daemon)` — exactly today's behavior. Pass `postgresBackend(...)` when
289
+ * Postgres is the source of truth (the daemon leg then serves only the read side). */
290
+ backend?: MutationBackend;
291
+ /** The typed schema (`createSchema`/`refineSchema`). Required only when a mutator uses the LOGICAL
292
+ * write vocabulary (`tx.insert`/`update`/`upsert`/`insertIgnore`/`delete`/`row`) — it drives the
293
+ * dialect SQL renderer (column order, pk, quoting). A logical op with no schema configured throws
294
+ * loudly. Pure raw-`tx.exec` mutators do not need it. */
295
+ schema?: Schema;
171
296
  queries?: ApiQueries<User>;
172
297
  runQuery?: RunQuery<User>;
173
298
  mutators?: ApiMutators<User>;
174
299
  authorizeQuery?: Authorizer<AuthorizeQueryInput<User>>;
175
300
  authorizeMutation?: Authorizer<AuthorizeMutationInput<User>>;
301
+ /** The room write-authority gate (§5.3.1): validates the caller is a placed room —
302
+ * the epoch-bound flush credential rides `context.request`, and what it means is
303
+ * the app's to define. The room endpoints are DISABLED (403) until this is set:
304
+ * hosting a write authority is an explicit opt-in, never a default. */
305
+ authorizeRoom?: Authorizer<ApiContext<User>>;
176
306
  routes?: Partial<RindleApiRoutes>;
177
307
  mode?: StreamMode;
178
308
  materializationPolicy?:
@@ -241,6 +371,18 @@ export interface RindleApiServer<User> {
241
371
  body: unknown,
242
372
  context: ApiContext<User>,
243
373
  ): Promise<PushMutationResponse | PushMutationResponse[]>;
374
+ /** The room's flush (§5.3.1): gate on `authorizeRoom`, forward the txn to the write
375
+ * authority, and pass the store's verdict through VERBATIM — `200 {applied, cv}`,
376
+ * `409 {error:"fenced"|"conflict", …}`, or the loud identity `500`. Write
377
+ * `status` + `body` as-is; the room's `httpAuthority` decodes them. */
378
+ handleApplyRowChangeTxnJson(
379
+ body: unknown,
380
+ context: ApiContext<User>,
381
+ ): Promise<RoomHostResponse>;
382
+ /** Claim the next placement epoch for a doc (§2.5), same gate + envelope. */
383
+ handleClaimRoomEpochJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
384
+ /** The room's boot probe (§3.3), same gate + envelope. */
385
+ handleRoomLmidsJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
244
386
  }
245
387
 
246
388
  export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
@@ -297,9 +439,46 @@ export class SplitDaemonClient implements RindleDaemonClient {
297
439
  rejectMutation(input: MutationRejection): Promise<MutationRejectionOutput> {
298
440
  return this.writes.rejectMutation(input);
299
441
  }
442
+ // Interactive mutation sessions hold the MASTER's write transaction — never a replica's
443
+ // (DAEMON-INTERACTIVE-TXN-DESIGN.md §4.1; the follower write-fence enforces the same).
444
+ beginMutationSession(input: MutationSessionBegin): Promise<MutationSessionBeginOutput> {
445
+ const begin = this.writes.beginMutationSession?.bind(this.writes);
446
+ if (!begin) return Promise.reject(new Error("the write master lacks mutation sessions"));
447
+ return begin(input);
448
+ }
449
+ execInMutationSession(input: MutationSessionExec): Promise<unknown> {
450
+ const exec = this.writes.execInMutationSession?.bind(this.writes);
451
+ if (!exec) return Promise.reject(new Error("the write master lacks mutation sessions"));
452
+ return exec(input);
453
+ }
454
+ queryInMutationSession(input: MutationSessionQuery): Promise<SqlReadOutput> {
455
+ const query = this.writes.queryInMutationSession?.bind(this.writes);
456
+ if (!query) return Promise.reject(new Error("the write master lacks mutation sessions"));
457
+ return query(input);
458
+ }
459
+ commitMutationSession(input: MutationSessionRef): Promise<SqlTxnOutput> {
460
+ const commit = this.writes.commitMutationSession?.bind(this.writes);
461
+ if (!commit) return Promise.reject(new Error("the write master lacks mutation sessions"));
462
+ return commit(input);
463
+ }
464
+ rollbackMutationSession(input: MutationSessionRef): Promise<unknown> {
465
+ const rollback = this.writes.rollbackMutationSession?.bind(this.writes);
466
+ if (!rollback) return Promise.reject(new Error("the write master lacks mutation sessions"));
467
+ return rollback(input);
468
+ }
300
469
  applyRowChangeTxn(input: RowChangeTxn): Promise<RowChangeTxnOutput> {
301
470
  return this.writes.applyRowChangeTxn(input);
302
471
  }
472
+ claimRoomEpoch(input: ClaimRoomEpochInput): Promise<ClaimRoomEpochOutput> {
473
+ const claim = this.writes.claimRoomEpoch?.bind(this.writes);
474
+ if (!claim) return Promise.reject(new Error("the write master lacks claimRoomEpoch"));
475
+ return claim(input);
476
+ }
477
+ roomLmids(input: RoomLmidsInput): Promise<RoomLmidsOutput> {
478
+ const lmids = this.writes.roomLmids?.bind(this.writes);
479
+ if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
480
+ return lmids(input);
481
+ }
303
482
  migrate(input: MigrateInput): Promise<MigrateOutput> {
304
483
  return this.writes.migrate(input);
305
484
  }
@@ -316,6 +495,688 @@ export class SplitDaemonClient implements RindleDaemonClient {
316
495
  }
317
496
  }
318
497
 
498
+ // --------------------------------------------------------------------------- dialect SQL renderer
499
+ //
500
+ // A logical {@link MutationOp} → dialect `SqlStatement`. The whole per-dialect delta is the
501
+ // PLACEHOLDER STYLE (`?` vs `$n`): identifiers are always double-quoted (SQLite tolerates it, PG
502
+ // requires it — `user` is reserved, camelCase folds), and upserts use portable `ON CONFLICT` (both
503
+ // engines). So `sqliteDialect`/`postgresDialect` differ only in `placeholder`.
504
+
505
+ /** A SQL dialect for the logical mutation renderer. */
506
+ export interface SqlDialect {
507
+ readonly name: "sqlite" | "postgres";
508
+ /** Render the i-th (1-based) bind placeholder. sqlite: `?`; postgres: `$i`. */
509
+ placeholder(oneBased: number): string;
510
+ /** Optional value coercion hook (e.g. a future SQLite `0/1` boolean). Default: identity. */
511
+ encodeValue?(v: WireValue, type: ColType): WireValue;
512
+ }
513
+
514
+ export const sqliteDialect: SqlDialect = { name: "sqlite", placeholder: () => "?" };
515
+ export const postgresDialect: SqlDialect = { name: "postgres", placeholder: (i) => `$${i}` };
516
+
517
+ /** Per-table metadata the renderer needs (all reachable from a `TableMeta`). */
518
+ export interface TableRenderMeta {
519
+ /** Columns in schema (wire) order — the stable INSERT column list + completeness check. */
520
+ columns: string[];
521
+ /** Primary-key column NAMES — the WHERE / ON CONFLICT target / SET partition. */
522
+ pkNames: string[];
523
+ /** Column name → declared type (only consulted by {@link SqlDialect.encodeValue}). */
524
+ types: Record<string, ColType>;
525
+ }
526
+
527
+ export type RenderIndex = Record<string, TableRenderMeta>;
528
+
529
+ /** Build the {@link RenderIndex} from a typed schema (`schema.tables[name]` is a `TableMeta`). */
530
+ export function buildRenderIndex(schema: Schema): RenderIndex {
531
+ const out: RenderIndex = {};
532
+ const tables = (schema as unknown as { tables: Record<string, { columns: Record<string, { type: ColType }>; primaryKey: readonly string[] }> }).tables;
533
+ for (const name of Object.keys(tables)) {
534
+ const meta = tables[name];
535
+ const columns = Object.keys(meta.columns);
536
+ const types: Record<string, ColType> = {};
537
+ for (const c of columns) types[c] = meta.columns[c].type;
538
+ out[name] = { columns, pkNames: [...meta.primaryKey], types };
539
+ }
540
+ return out;
541
+ }
542
+
543
+ const quoteIdent = (name: string): string => `"${name.replace(/"/g, '""')}"`;
544
+
545
+ function tableMeta(render: RenderIndex, table: string): TableRenderMeta {
546
+ const meta = render[table];
547
+ if (!meta) {
548
+ const known = Object.keys(render);
549
+ throw new Error(
550
+ `logical mutator write to unknown table ${JSON.stringify(table)} — did you pass \`schema\` to createRindleApiServer? known tables: ${known.length ? known.join(", ") : "(none — no schema configured)"}`,
551
+ );
552
+ }
553
+ return meta;
554
+ }
555
+
556
+ /** Validate a keyed row against a table: reject unknown columns; require the pk columns; with
557
+ * `full`, require EVERY column (an insert carries a whole row — there are no defaults). Mirrors the
558
+ * client `trackingTx.checkColumns` messages so an author sees the same error on both tiers. */
559
+ function checkColumns(table: string, obj: KeyedRow, meta: TableRenderMeta, full: boolean): void {
560
+ const unknown = Object.keys(obj).filter((k) => !meta.columns.includes(k));
561
+ if (unknown.length) {
562
+ throw new Error(`unknown column${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} on ${table} — columns: ${meta.columns.join(", ")}`);
563
+ }
564
+ const required = full ? meta.columns : meta.pkNames;
565
+ const missing = required.filter((c) => !(c in obj));
566
+ if (missing.length) {
567
+ throw new Error(`missing ${full ? "column" : "primary-key column"}${missing.length > 1 ? "s" : ""} ${missing.join(", ")} on ${table}`);
568
+ }
569
+ }
570
+
571
+ const encode = (dialect: SqlDialect, v: WireValue, type: ColType): WireValue =>
572
+ dialect.encodeValue ? dialect.encodeValue(v, type) : v;
573
+
574
+ /** Render one {@link MutationOp} to a `{sql, params}` for the dialect, or `null` for a no-op (an
575
+ * `update` whose row names only pk columns — nothing to SET, matching the client's no-op edit). */
576
+ export function renderOp(op: MutationOp, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement | null {
577
+ const t = quoteIdent(op.table);
578
+ const params: WireValue[] = [];
579
+ // Bind a value and return its placeholder at the correct 1-based index (post-push length).
580
+ const bind = (c: string, row: KeyedRow): string => {
581
+ params.push(encode(dialect, row[c], meta.types[c]));
582
+ return dialect.placeholder(params.length);
583
+ };
584
+
585
+ if (op.kind === "insert" || op.kind === "insertIgnore" || op.kind === "upsert") {
586
+ checkColumns(op.table, op.row, meta, true);
587
+ const cols = meta.columns;
588
+ const values = cols.map((c) => bind(c, op.row));
589
+ let sql = `INSERT INTO ${t} (${cols.map(quoteIdent).join(", ")}) VALUES (${values.join(", ")})`;
590
+ if (op.kind === "insertIgnore") {
591
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) DO NOTHING`;
592
+ } else if (op.kind === "upsert") {
593
+ const nonPk = cols.filter((c) => !meta.pkNames.includes(c));
594
+ sql += ` ON CONFLICT (${meta.pkNames.map(quoteIdent).join(", ")}) `;
595
+ sql += nonPk.length
596
+ ? `DO UPDATE SET ${nonPk.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(", ")}`
597
+ : `DO NOTHING`;
598
+ }
599
+ return { sql, params };
600
+ }
601
+
602
+ if (op.kind === "update") {
603
+ checkColumns(op.table, op.row, meta, false);
604
+ const setCols = meta.columns.filter((c) => !meta.pkNames.includes(c) && c in op.row);
605
+ if (!setCols.length) return null; // pk-only row → nothing to change (client no-op edit)
606
+ const setSql = setCols.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
607
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.row)}`);
608
+ return { sql: `UPDATE ${t} SET ${setSql.join(", ")} WHERE ${whereSql.join(" AND ")}`, params };
609
+ }
610
+
611
+ // delete
612
+ checkColumns(op.table, op.pk, meta, false);
613
+ const whereSql = meta.pkNames.map((c) => `${quoteIdent(c)} = ${bind(c, op.pk)}`);
614
+ return { sql: `DELETE FROM ${t} WHERE ${whereSql.join(" AND ")}`, params };
615
+ }
616
+
617
+ /** Render a point read (`tx.row`) — `SELECT <cols> FROM "T" WHERE <pk>` — for read-your-writes. */
618
+ export function renderPointRead(table: string, pk: KeyedRow, meta: TableRenderMeta, dialect: SqlDialect): SqlStatement {
619
+ checkColumns(table, pk, meta, false);
620
+ const params: WireValue[] = [];
621
+ const whereSql = meta.pkNames.map((c) => {
622
+ params.push(encode(dialect, pk[c], meta.types[c]));
623
+ return `${quoteIdent(c)} = ${dialect.placeholder(params.length)}`;
624
+ });
625
+ const cols = meta.columns.map(quoteIdent).join(", ");
626
+ return { sql: `SELECT ${cols} FROM ${quoteIdent(table)} WHERE ${whereSql.join(" AND ")}`, params };
627
+ }
628
+
629
+ /** Map a driver row (column-name keyed) to a {@link KeyedRow} over the table's known columns. */
630
+ function rowToKeyed(row: Record<string, unknown> | undefined, meta: TableRenderMeta): KeyedRow | undefined {
631
+ if (!row) return undefined;
632
+ const out: KeyedRow = {};
633
+ for (const c of meta.columns) out[c] = row[c] as WireValue;
634
+ return out;
635
+ }
636
+
637
+ // --------------------------------------------------------------------------- backends + server tx
638
+
639
+ /** Thrown (wrapping the driver error) by a server tx's DB calls, so the seam can tell an INFRA
640
+ * failure (retry) from a mutator-body throw (business rejection). */
641
+ export class BackendError extends Error {
642
+ readonly driverError: unknown;
643
+ constructor(driverError: unknown) {
644
+ super(driverError instanceof Error ? driverError.message : String(driverError));
645
+ this.name = "BackendError";
646
+ this.driverError = driverError;
647
+ }
648
+ }
649
+
650
+ /** The rindle/daemon server tx: logical writes render to SQLite and ACCUMULATE into one batch;
651
+ * raw `exec` accumulates too; `row` reads COMMITTED state through the daemon (no read-your-writes,
652
+ * the one interactive-txn limitation of the daemon backend). */
653
+ /** Build the compiler {@link Catalog} for ONE ast from the render index: columns/pk from the
654
+ * schema; relationship cardinality from the AST ITSELF — a Rindle relationship is declared at
655
+ * the query site (`sub(alias, rel)` / `.one()`), never on the schema, so the alias→cardinality
656
+ * map is inherently per-query. `columnTypes` are stubs: the sqlite dialect binds natives and
657
+ * never consults them (DAEMON-INTERACTIVE-TXN §5.4 — no casts). */
658
+ function catalogFor(render: RenderIndex, root: Ast): Catalog {
659
+ const tables: Record<string, TableSchema> = {};
660
+ const ensure = (table: string): TableSchema => {
661
+ const existing = tables[table];
662
+ if (existing) return existing;
663
+ const meta = tableMeta(render, table);
664
+ const columnTypes: Record<string, QueryColumnType> = {};
665
+ for (const c of meta.columns) columnTypes[c] = { type: "text", isEnum: false, isArray: false };
666
+ return (tables[table] = {
667
+ columns: [...meta.columns],
668
+ primaryKey: [...meta.pkNames],
669
+ columnTypes,
670
+ relationships: {},
671
+ });
672
+ };
673
+ const walkCondition = (cond: Condition | undefined): void => {
674
+ if (!cond) return;
675
+ if (cond.type === "and" || cond.type === "or") {
676
+ for (const c of cond.conditions) walkCondition(c);
677
+ } else if (cond.type === "correlatedSubquery") {
678
+ walkAst(cond.related.subquery);
679
+ }
680
+ };
681
+ const walkAst = (ast: Ast): void => {
682
+ const t = ensure(ast.table);
683
+ for (const rel of ast.related ?? []) {
684
+ const alias = rel.subquery.alias;
685
+ if (alias != null) t.relationships[alias] = rel.subquery.one === true ? "one" : "many";
686
+ walkAst(rel.subquery);
687
+ }
688
+ walkCondition(ast.where);
689
+ };
690
+ walkAst(root);
691
+ return { tables };
692
+ }
693
+
694
+ /** The control-flow unwind for a begin-absorbed replay (DAEMON-INTERACTIVE-TXN §4.1): thrown
695
+ * from the first read so the mutator body stops re-running an already-committed envelope; the
696
+ * backend answers with the tx's latched authoritative output. Never surfaces to users. */
697
+ class AbsorbedReplay extends Error {
698
+ constructor() {
699
+ super("mutation absorbed by mid dedup at session begin");
700
+ }
701
+ }
702
+
703
+ /**
704
+ * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
705
+ * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
706
+ * `/execute-sql-txn`, byte-identical to prior behavior — and LAZILY UPGRADES to an interactive
707
+ * mutation session at the mutator's first read: `begin` carries the envelope identity, the
708
+ * accumulated statement prefix (sound to replay — nothing before the first read observed DB
709
+ * state, §5.2), and the read itself, so a one-read mutator pays exactly one extra round trip.
710
+ * From then on reads run THROUGH the open transaction (read-your-writes — PG parity, and no
711
+ * read-then-write race) and writes buffer locally, flushing before the next read/commit: k
712
+ * reads cost k+2 round trips regardless of write count.
713
+ *
714
+ * Begin-time mid dedup can ABSORB the envelope (a redelivery whose commit response was lost):
715
+ * the replay output is latched on {@link DaemonLazyTx.absorbed} and {@link AbsorbedReplay}
716
+ * unwinds the body — the latch (not the throw) is authoritative, so a mutator that swallows
717
+ * the unwind still cannot re-apply (no session opened; buffered writes are never shipped).
718
+ * A daemon client without session support keeps the LEGACY committed-state point read.
719
+ */
720
+ class DaemonLazyTx implements ServerMutationTx {
721
+ /** Pre-upgrade: the accumulated batch/prefix. Post-upgrade: writes buffered for the next flush. */
722
+ private readonly stmts: SqlStatement[] = [];
723
+ private readonly render: RenderIndex;
724
+ private readonly daemon: RindleDaemonClient;
725
+ private readonly envelope: MutationEnvelope;
726
+ private sessionId?: string;
727
+ /** The begin-absorbed replay output (§4.1), latched for the backend. */
728
+ absorbed?: SqlTxnOutput;
729
+ idempotencyKey?: string;
730
+
731
+ constructor(render: RenderIndex, daemon: RindleDaemonClient, envelope: MutationEnvelope) {
732
+ this.render = render;
733
+ this.daemon = daemon;
734
+ this.envelope = envelope;
735
+ }
736
+
737
+ /** True once the tx upgraded to an interactive session (the backend then commits it). */
738
+ get session(): boolean {
739
+ return this.sessionId !== undefined;
740
+ }
741
+
742
+ get statements(): readonly SqlStatement[] {
743
+ return this.stmts;
744
+ }
745
+
746
+ exec(sql: string, params: WireValue[] = []): void {
747
+ this.stmts.push({ sql, params });
748
+ }
749
+
750
+ private push(op: MutationOp): Promise<void> {
751
+ const rendered = renderOp(op, tableMeta(this.render, op.table), sqliteDialect);
752
+ if (rendered) this.stmts.push(rendered);
753
+ return Promise.resolve();
754
+ }
755
+
756
+ insert(table: string, row: KeyedRow): Promise<void> {
757
+ return this.push({ kind: "insert", table, row });
758
+ }
759
+ update(table: string, row: KeyedRow): Promise<void> {
760
+ return this.push({ kind: "update", table, row });
761
+ }
762
+ upsert(table: string, row: KeyedRow): Promise<void> {
763
+ return this.push({ kind: "upsert", table, row });
764
+ }
765
+ insertIgnore(table: string, row: KeyedRow): Promise<void> {
766
+ return this.push({ kind: "insertIgnore", table, row });
767
+ }
768
+ delete(table: string, pk: KeyedRow): Promise<void> {
769
+ return this.push({ kind: "delete", table, pk });
770
+ }
771
+
772
+ async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
773
+ const read = renderPointRead(table, pk, tableMeta(this.render, table), sqliteDialect);
774
+ const out = await this.readThroughTxn(read);
775
+ const cells = out.rows[0];
776
+ if (!cells) return undefined;
777
+ const keyed: KeyedRow = {}; // the daemon returns positional cells — zip with `cols`
778
+ out.cols.forEach((c, i) => (keyed[c] = cells[i]));
779
+ return keyed;
780
+ }
781
+
782
+ /** A full-shape read inside the open transaction (§5.4): compile to ONE SQLite `SELECT`
783
+ * (native bind params, no casts — SQLite is the canonical store), ride the session like
784
+ * `row` (including the lazy upgrade / begin ride-along), parse the single JSON cell. */
785
+ async query(q: Ast | Query<any, any, any>): Promise<unknown> {
786
+ const ast = typeof (q as Query<any, any, any>).ast === "function" ? (q as Query<any, any, any>).ast() : (q as Ast);
787
+ const compiled = compileQueryAst(ast, catalogFor(this.render, ast), { dialect: "sqlite" });
788
+ const out = await this.readThroughTxn({ sql: compiled.sql, params: compiled.params as WireValue[] });
789
+ const cell = out.rows[0]?.[0];
790
+ if (typeof cell !== "string") return ast.one === true ? null : [];
791
+ return JSON.parse(cell) as unknown;
792
+ }
793
+
794
+ /** Run one read: upgrade to a session at the first (§5.1), ride the open one after, or fall
795
+ * back to the legacy committed-state read when the daemon client lacks sessions. */
796
+ private async readThroughTxn(read: SqlStatement): Promise<SqlReadOutput> {
797
+ if (this.absorbed) throw new AbsorbedReplay();
798
+ if (!this.daemon.beginMutationSession) {
799
+ try {
800
+ return await this.daemon.executeSqlRead({ sql: read.sql, params: read.params });
801
+ } catch (err) {
802
+ throw new BackendError(err);
803
+ }
804
+ }
805
+ try {
806
+ if (this.sessionId === undefined) {
807
+ const opened = await this.daemon.beginMutationSession({
808
+ clientID: this.envelope.clientID,
809
+ mid: this.envelope.mid,
810
+ statements: this.stmts.splice(0),
811
+ query: read,
812
+ });
813
+ if (opened.absorbed) {
814
+ const { absorbed: _a, sessionId: _s, read: _r, ...output } = opened;
815
+ this.absorbed = output as SqlTxnOutput;
816
+ throw new AbsorbedReplay();
817
+ }
818
+ if (!opened.sessionId || !opened.read) {
819
+ throw new Error(`malformed mutate-session begin reply: ${JSON.stringify(opened)}`);
820
+ }
821
+ this.sessionId = opened.sessionId;
822
+ return opened.read;
823
+ }
824
+ await this.flush();
825
+ return await this.daemon.queryInMutationSession!({
826
+ sessionId: this.sessionId,
827
+ sql: read.sql,
828
+ params: read.params,
829
+ });
830
+ } catch (err) {
831
+ if (err instanceof AbsorbedReplay || err instanceof BackendError) throw err;
832
+ throw new BackendError(err);
833
+ }
834
+ }
835
+
836
+ /** Ship buffered writes into the open session, order-preserving; a no-op when none pend. */
837
+ private async flush(): Promise<void> {
838
+ if (this.stmts.length === 0) return;
839
+ await this.daemon.execInMutationSession!({
840
+ sessionId: this.sessionId!,
841
+ statements: this.stmts.splice(0),
842
+ });
843
+ }
844
+
845
+ /** Flush + commit the open session — the daemon stamps lmid co-transactionally (§4.4) and
846
+ * answers the same shape `/execute-sql-txn` does. */
847
+ async commitSession(): Promise<SqlTxnOutput> {
848
+ try {
849
+ await this.flush();
850
+ return await this.daemon.commitMutationSession!({ sessionId: this.sessionId! });
851
+ } catch (err) {
852
+ throw err instanceof BackendError ? err : new BackendError(err);
853
+ }
854
+ }
855
+
856
+ /** Best-effort rollback (the daemon's deadline is the backstop). MUST be awaited before a
857
+ * follow-up `/reject-mutation`: that lmid-only commit needs the writer this session holds. */
858
+ async rollbackSessionQuietly(): Promise<void> {
859
+ if (this.sessionId === undefined) return;
860
+ const sessionId = this.sessionId;
861
+ this.sessionId = undefined;
862
+ try {
863
+ await this.daemon.rollbackMutationSession!({ sessionId });
864
+ } catch {
865
+ // Unreachable daemon / already-expired session: the deadline rollback covers it.
866
+ }
867
+ }
868
+ }
869
+
870
+ /** The Postgres server tx: a REAL interactive transaction. Logical writes render to `$n` and run
871
+ * LIVE against the open txn; raw `exec` runs live too (after `rewrite`); `row` reads the open txn
872
+ * (read-your-writes). Ops append to an internally-serialized chain so order holds even when a legacy
873
+ * sync mutator does not `await`; the backend drains the chain (`settle`) before the lmid upsert. */
874
+ class PgLiveTx implements ServerMutationTx {
875
+ private chain: Promise<void> = Promise.resolve();
876
+ private readonly stmts: SqlStatement[] = [];
877
+ private readonly q: PgQuery;
878
+ private readonly render: RenderIndex;
879
+ private readonly rewrite: (sql: string) => string;
880
+ idempotencyKey?: string;
881
+
882
+ constructor(q: PgQuery, render: RenderIndex, rewrite: (sql: string) => string) {
883
+ this.q = q;
884
+ this.render = render;
885
+ this.rewrite = rewrite;
886
+ }
887
+
888
+ get statements(): readonly SqlStatement[] {
889
+ return this.stmts;
890
+ }
891
+
892
+ settle(): Promise<void> {
893
+ return this.chain;
894
+ }
895
+
896
+ private execLive(sql: string, params: WireValue[]): void {
897
+ this.chain = this.chain.then(async () => {
898
+ try {
899
+ await this.q.exec(sql, params as unknown[]);
900
+ } catch (err) {
901
+ throw new BackendError(err);
902
+ }
903
+ });
904
+ }
905
+
906
+ exec(sql: string, params: WireValue[] = []): void {
907
+ const stmt = { sql: this.rewrite(sql), params };
908
+ this.stmts.push(stmt);
909
+ this.execLive(stmt.sql, params);
910
+ }
911
+
912
+ private write(op: MutationOp): Promise<void> {
913
+ let rendered: SqlStatement | null;
914
+ try {
915
+ rendered = renderOp(op, tableMeta(this.render, op.table), postgresDialect);
916
+ } catch (err) {
917
+ return Promise.reject(err); // a validation error (business rejection), synchronous shape
918
+ }
919
+ if (rendered) this.execLive(rendered.sql, rendered.params ?? []);
920
+ return this.chain;
921
+ }
922
+
923
+ insert(table: string, row: KeyedRow): Promise<void> {
924
+ return this.write({ kind: "insert", table, row });
925
+ }
926
+ update(table: string, row: KeyedRow): Promise<void> {
927
+ return this.write({ kind: "update", table, row });
928
+ }
929
+ upsert(table: string, row: KeyedRow): Promise<void> {
930
+ return this.write({ kind: "upsert", table, row });
931
+ }
932
+ insertIgnore(table: string, row: KeyedRow): Promise<void> {
933
+ return this.write({ kind: "insertIgnore", table, row });
934
+ }
935
+ delete(table: string, pk: KeyedRow): Promise<void> {
936
+ return this.write({ kind: "delete", table, pk });
937
+ }
938
+ async row(table: string, pk: KeyedRow): Promise<KeyedRow | undefined> {
939
+ const meta = tableMeta(this.render, table);
940
+ const read = renderPointRead(table, pk, meta, postgresDialect); // validates before draining
941
+ await this.settle(); // read-your-writes: drain queued writes first
942
+ let rows: Array<Record<string, unknown>>;
943
+ try {
944
+ rows = await this.q.query(read.sql, read.params as unknown[]);
945
+ } catch (err) {
946
+ throw new BackendError(err);
947
+ }
948
+ return rowToKeyed(rows[0], meta);
949
+ }
950
+
951
+ query(): Promise<unknown> {
952
+ // The compiler's postgres dialect ships (@rindle/query-compiler); what remains is the §7
953
+ // static-catalog + driver-pin wiring (POSTGRES-READ-COMPILER-DESIGN.md Phase B).
954
+ return Promise.reject(
955
+ new Error(
956
+ "tx.query is not wired on the Postgres backend yet (POSTGRES-READ-COMPILER-DESIGN.md Phase B) — use tx.row for point reads meanwhile",
957
+ ),
958
+ );
959
+ }
960
+ }
961
+
962
+ /**
963
+ * The default {@link MutationBackend}. A pure-write mutator keeps the historical shape: writes
964
+ * ACCUMULATE and ship as ONE batch to `/execute-sql-txn`, which stamps `lmid` co-transactionally
965
+ * (and `/reject-mutation` advances it past a rejected mid) — byte-identical behavior. A
966
+ * READ-bearing mutator lazily upgrades to an interactive mutation session
967
+ * (DAEMON-INTERACTIVE-TXN-DESIGN.md): reads are read-your-writes through the open transaction
968
+ * (PG parity), the commit stamps `lmid` in the same atomic unit, and a begin-absorbed replay
969
+ * short-circuits without re-running the body.
970
+ */
971
+ export function daemonBackend(daemon: RindleDaemonClient): MutationBackend {
972
+ return {
973
+ dialect: sqliteDialect,
974
+ async runMutation({ envelope, render, run }) {
975
+ const tx = new DaemonLazyTx(render, daemon, envelope);
976
+ try {
977
+ await run(tx);
978
+ } catch (err) {
979
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
980
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
981
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
982
+ if (err instanceof BackendError) {
983
+ await tx.rollbackSessionQuietly();
984
+ throw err.driverError; // infra — never a user rejection
985
+ }
986
+ const reason = errMessage(err);
987
+ // Data first, watermark second: the rollback releases the single writer that the
988
+ // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
989
+ await tx.rollbackSessionQuietly();
990
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
991
+ return { accepted: false, reason, output };
992
+ }
993
+ if (tx.absorbed) return { accepted: true, output: tx.absorbed };
994
+ if (tx.session) {
995
+ try {
996
+ return { accepted: true, output: await tx.commitSession() };
997
+ } catch (err) {
998
+ if (err instanceof BackendError) throw err.driverError; // infra (client retries; dedup absorbs)
999
+ throw err;
1000
+ }
1001
+ }
1002
+ const txn: SqlTxn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
1003
+ if (tx.idempotencyKey !== undefined) txn.idempotencyKey = tx.idempotencyKey;
1004
+ return { accepted: true, output: await daemon.executeSqlTxn(txn) };
1005
+ },
1006
+ reject({ envelope, reason }) {
1007
+ return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1008
+ },
1009
+ };
1010
+ }
1011
+
1012
+ /** The query surface a {@link PostgresPlugger} transaction exposes. `exec` runs one statement;
1013
+ * `query` returns rows keyed by column name (read-your-own-writes inside the txn). */
1014
+ export interface PgQuery {
1015
+ exec(sql: string, params?: unknown[]): Promise<void>;
1016
+ query(sql: string, params?: unknown[]): Promise<Array<Record<string, unknown>>>;
1017
+ }
1018
+
1019
+ /** The thin driver adapter that keeps `pg` / `postgres.js` out of this package's dependencies
1020
+ * (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6.2): run `fn` inside ONE transaction — commit on
1021
+ * resolve, roll back on throw. {@link pgPoolPlugger} adapts a node-postgres `Pool`. */
1022
+ export interface PostgresPlugger {
1023
+ transaction<T>(fn: (q: PgQuery) => Promise<T>): Promise<T>;
1024
+ }
1025
+
1026
+ export interface PostgresBackendOptions {
1027
+ /** Rewrite each MUTATOR statement's SQL before it runs (never the lmid upsert). The intended
1028
+ * use is dialect bridging for a dual-topology app whose mutators are written SQLite-style:
1029
+ * pass {@link questionToDollarParams} to convert `?` placeholders to `$1..$n`. */
1030
+ rewriteSql?: (sql: string) => string;
1031
+ }
1032
+
1033
+ /** The §2.3 upsert, verbatim from the contract: monotonic via GREATEST, keyed by client. The
1034
+ * identifiers are lowercase so quoting is cosmetic, but quote-everything is the repo's PG rule. */
1035
+ const LMID_UPSERT = `INSERT INTO "_rindle_client_mutations" ("client_id", "last_mutation_id")
1036
+ VALUES ($1, $2)
1037
+ ON CONFLICT ("client_id") DO UPDATE
1038
+ SET "last_mutation_id" = GREATEST("_rindle_client_mutations"."last_mutation_id", EXCLUDED."last_mutation_id")`;
1039
+
1040
+ /**
1041
+ * The BYO-Postgres {@link MutationBackend} (`BYO-POSTGRES-LMID-CONTRACT-DESIGN.md` §6.3): one PG
1042
+ * transaction runs the mutator's statements and ALWAYS upserts `_rindle_client_mutations` —
1043
+ * the upsert sits outside any acceptance guard by construction, so the §2.4 footgun (a rejection
1044
+ * that forgets to advance `lmid` and wedges the client's pending queue) cannot be written.
1045
+ *
1046
+ * Confirmation does NOT come from this call's response: the lmid row rides the same PG commit
1047
+ * through CDC → relay → follower and reaches the client in the same coherent release as the
1048
+ * data (§8.2 relocated upstream). A rejection's `reason` still returns on the HTTP reply, but
1049
+ * nothing rejection-shaped travels the replication path — the optimistic prediction snaps back
1050
+ * when the advanced `lmid` arrives.
1051
+ */
1052
+ export function postgresBackend(plugger: PostgresPlugger, opts: PostgresBackendOptions = {}): MutationBackend {
1053
+ const rewrite = opts.rewriteSql ?? ((sql: string) => sql);
1054
+ // A tagged business rejection escaping `plugger.transaction` — the plugger rolls the data back on
1055
+ // any throw; this marker distinguishes "mutator said no" (reject) from an infra failure (rethrow).
1056
+ class RejectSignal extends Error {}
1057
+ const lmidOnly = (envelope: MutationEnvelope): Promise<SqlTxnOutput> =>
1058
+ plugger.transaction(async (q) => {
1059
+ await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
1060
+ return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
1061
+ });
1062
+ return {
1063
+ dialect: postgresDialect,
1064
+ async runMutation({ envelope, render, run }) {
1065
+ try {
1066
+ const output = await plugger.transaction(async (q) => {
1067
+ const tx = new PgLiveTx(q, render, rewrite);
1068
+ try {
1069
+ await run(tx);
1070
+ await tx.settle(); // drain any un-awaited queued writes before the lmid stamp
1071
+ } catch (err) {
1072
+ if (err instanceof BackendError) throw err; // infra → rollback + propagate
1073
+ throw new RejectSignal(errMessage(err)); // business → rollback data, tag for §2.4
1074
+ }
1075
+ // ALWAYS on the accepted path, SAME transaction (§2.2): the lmid upsert commits with data.
1076
+ await q.exec(LMID_UPSERT, [envelope.clientID, envelope.mid]);
1077
+ return { applied: true, lmidAdvances: [{ clientID: envelope.clientID, lmid: envelope.mid }] };
1078
+ });
1079
+ return { accepted: true, output };
1080
+ } catch (err) {
1081
+ if (err instanceof RejectSignal) {
1082
+ // Data rolled back; STILL advance lmid alone (§2.4 — the client's queue must drain).
1083
+ return { accepted: false, reason: err.message, output: await lmidOnly(envelope) };
1084
+ }
1085
+ if (err instanceof BackendError) throw err.driverError; // infra
1086
+ throw err;
1087
+ }
1088
+ },
1089
+ reject: ({ envelope }) => lmidOnly(envelope).then(() => undefined),
1090
+ };
1091
+ }
1092
+
1093
+ /** The slice of a node-postgres `Pool` the plugger needs — structural, so `pg` stays a
1094
+ * dependency of the APP, never of this package. */
1095
+ export interface PgPoolLike {
1096
+ connect(): Promise<{
1097
+ query(sql: string, params?: unknown[]): Promise<{ rows: Array<Record<string, unknown>> }>;
1098
+ release(err?: unknown): void;
1099
+ }>;
1100
+ }
1101
+
1102
+ /** Adapt a node-postgres `Pool` (or anything pool-shaped) to a {@link PostgresPlugger}:
1103
+ * one client per transaction, `BEGIN`/`COMMIT` bracketing, `ROLLBACK` + rethrow on failure. */
1104
+ export function pgPoolPlugger(pool: PgPoolLike): PostgresPlugger {
1105
+ return {
1106
+ async transaction<T>(fn: (q: PgQuery) => Promise<T>): Promise<T> {
1107
+ const client = await pool.connect();
1108
+ try {
1109
+ await client.query("BEGIN");
1110
+ const q: PgQuery = {
1111
+ exec: async (sql, params) => {
1112
+ await client.query(sql, params);
1113
+ },
1114
+ query: async (sql, params) => (await client.query(sql, params)).rows,
1115
+ };
1116
+ const out = await fn(q);
1117
+ await client.query("COMMIT");
1118
+ return out;
1119
+ } catch (err) {
1120
+ try {
1121
+ await client.query("ROLLBACK");
1122
+ } catch {
1123
+ // the connection may already be unusable; the original error is the one that matters
1124
+ }
1125
+ throw err;
1126
+ } finally {
1127
+ client.release();
1128
+ }
1129
+ },
1130
+ };
1131
+ }
1132
+
1133
+ /**
1134
+ * Rewrite SQLite-style `?` positional placeholders to Postgres `$1..$n`, for mutators written
1135
+ * once and run against either backend (pass as {@link PostgresBackendOptions.rewriteSql}).
1136
+ * Skips `'…'` string literals (with `''` escapes), `"…"` quoted identifiers, `--` line comments,
1137
+ * and non-nested C-style block comments. Do not mix `?` and `$n` styles in one statement.
1138
+ */
1139
+ export function questionToDollarParams(sql: string): string {
1140
+ let out = "";
1141
+ let n = 0;
1142
+ let i = 0;
1143
+ while (i < sql.length) {
1144
+ const c = sql[i];
1145
+ if (c === "?") {
1146
+ out += `$${++n}`;
1147
+ i += 1;
1148
+ } else if (c === "'" || c === '"') {
1149
+ // consume the quoted span; a doubled quote is an escape inside it
1150
+ const quote = c;
1151
+ let j = i + 1;
1152
+ while (j < sql.length) {
1153
+ if (sql[j] === quote) {
1154
+ if (sql[j + 1] === quote) j += 2;
1155
+ else break;
1156
+ } else {
1157
+ j += 1;
1158
+ }
1159
+ }
1160
+ out += sql.slice(i, j + 1);
1161
+ i = j + 1;
1162
+ } else if (c === "-" && sql[i + 1] === "-") {
1163
+ const end = sql.indexOf("\n", i);
1164
+ const j = end === -1 ? sql.length : end;
1165
+ out += sql.slice(i, j);
1166
+ i = j;
1167
+ } else if (c === "/" && sql[i + 1] === "*") {
1168
+ const end = sql.indexOf("*/", i + 2);
1169
+ const j = end === -1 ? sql.length : end + 2;
1170
+ out += sql.slice(i, j);
1171
+ i = j;
1172
+ } else {
1173
+ out += c;
1174
+ i += 1;
1175
+ }
1176
+ }
1177
+ return out;
1178
+ }
1179
+
319
1180
  export function defineApiQueries<User, Q extends ApiQueries<User>>(queries: Q): Q {
320
1181
  return queries;
321
1182
  }
@@ -353,6 +1214,38 @@ export function defineApiMutators<User, M extends ApiMutators<User>>(mutators: M
353
1214
  return mutators;
354
1215
  }
355
1216
 
1217
+ /**
1218
+ * Bulk-register a SHARED (generator) mutator registry as server mutators — the mutator twin of
1219
+ * {@link registerQueries} (which does the same for co-located `defineQuery` values). Each shared
1220
+ * mutator carries its own arg validator (`shared(schema, gen)`), so this wraps every one with the
1221
+ * UNIVERSAL server triad and nothing else: parse the UNTRUSTED wire args (its `.args`), map the server
1222
+ * {@link MutationContext} to the shared {@link MutatorCtx} principal, and drive the SAME body the
1223
+ * client predicts ({@link runSharedMutation}). The point is that a shared mutator whose server run
1224
+ * adds NO authority beyond that triad needs no hand-written wrapper.
1225
+ *
1226
+ * Server-only AUTHORITY the client cannot predict (a title guard, an owner-gated cascade, a
1227
+ * `NOT EXISTS` dedup) stays an explicit {@link ApiMutator} that OVERRIDES the auto-wrapped default —
1228
+ * spread this first, then the overrides win by key:
1229
+ *
1230
+ * ```ts
1231
+ * mutators: defineApiMutators({
1232
+ * ...sharedApiMutators(sharedMutators, (ctx) => ({ user: requireUser(ctx.user) })),
1233
+ * createIssue: withTitleGuard(sharedMutators.createIssue), // + server-only policy
1234
+ * deleteIssue: async (tx, raw, ctx) => { ... }, // raw owner-gated cascade
1235
+ * }),
1236
+ * ```
1237
+ */
1238
+ export function sharedApiMutators<User>(
1239
+ registry: Record<string, SharedMutatorWithArgs<any>>,
1240
+ principal: (ctx: MutationContext<User>) => MutatorCtx,
1241
+ ): ApiMutators<User> {
1242
+ const out: ApiMutators<User> = {};
1243
+ for (const [name, mutator] of Object.entries(registry)) {
1244
+ out[name] = (tx, raw, ctx) => runSharedMutation(mutator, mutator.args.parse(raw), principal(ctx), tx);
1245
+ }
1246
+ return out;
1247
+ }
1248
+
356
1249
  export function queryResultToAst(result: ApiQueryResult): Ast {
357
1250
  if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
358
1251
  return result.ast();
@@ -360,9 +1253,112 @@ export function queryResultToAst(result: ApiQueryResult): Ast {
360
1253
  return result as Ast;
361
1254
  }
362
1255
 
1256
+ /** One exemplar invocation for {@link dumpQueryShapes} — the `args`/`user` a query is built with.
1257
+ * Literal values never matter to the dump (shapes are deduped with literals stripped); what an
1258
+ * exemplar buys is BRANCH coverage, so supply one per code path a query function can take
1259
+ * (an optional filter present/absent, each enum axis, …). */
1260
+ export interface ShapeExemplar<User = unknown> {
1261
+ args?: unknown;
1262
+ user?: User;
1263
+ }
1264
+
1265
+ /** The query-shapes document `rindle indices suggest` consumes: the app's synced tables (name +
1266
+ * primary key) and one wire AST per structurally distinct shape a registered query can build. */
1267
+ export interface QueryShapesDoc {
1268
+ tables: Array<{ name: string; primaryKey: string[] }>;
1269
+ queries: Array<{ name: string; ast: Ast }>;
1270
+ }
1271
+
1272
+ /**
1273
+ * Dump every registered named query's wire AST — feeder 1 ("exemplar enumeration") of
1274
+ * `rindle indices suggest` (docs/INDEXING.md applied mechanically to the query set).
1275
+ *
1276
+ * Because named queries are FUNCTIONS of `(args, ctx)`, one query can build structurally
1277
+ * different ASTs on different args; each exemplar invocation contributes its shape, and shapes
1278
+ * that differ only in literal values (a limit, a filter string) dedupe to one entry. A query
1279
+ * with no configured exemplars is invoked once with no args. The registry is the app's whole
1280
+ * server-side query surface, so the resulting document is the complete static shape set —
1281
+ * modulo arg-value-dependent branches, which need an exemplar (or runtime shape recording) to
1282
+ * surface.
1283
+ */
1284
+ export async function dumpQueryShapes<User>(opts: {
1285
+ schema: Schema;
1286
+ queries: ApiQueries<User>;
1287
+ exemplars?: Partial<Record<string, ReadonlyArray<ShapeExemplar<User>>>>;
1288
+ }): Promise<QueryShapesDoc> {
1289
+ const tables = Object.values(opts.schema.tables)
1290
+ // Local-only tables live in the browser's memory source, never a TableSource — no indexes.
1291
+ .filter((t) => t.local !== true)
1292
+ .map((t) => ({ name: t.name, primaryKey: [...t.primaryKey] }))
1293
+ .sort((a, b) => a.name.localeCompare(b.name));
1294
+ const queries: QueryShapesDoc["queries"] = [];
1295
+ for (const [name, query] of Object.entries(opts.queries).sort(([a], [b]) => a.localeCompare(b))) {
1296
+ const seen = new Set<string>();
1297
+ for (const ex of opts.exemplars?.[name] ?? [{}]) {
1298
+ const ast = queryResultToAst(await query({ user: ex.user as User }, ex.args));
1299
+ const key = JSON.stringify(normalizeShape(ast));
1300
+ if (seen.has(key)) continue;
1301
+ seen.add(key);
1302
+ queries.push({ name: seen.size > 1 ? `${name}#${seen.size}` : name, ast });
1303
+ }
1304
+ }
1305
+ return { tables, queries };
1306
+ }
1307
+
1308
+ /** The literal-stripped structure of an AST — the dedupe key for {@link dumpQueryShapes}. */
1309
+ function normalizeShape(ast: Ast): Record<string, unknown> {
1310
+ return {
1311
+ table: ast.table,
1312
+ where: ast.where && normalizeCondition(ast.where),
1313
+ related: ast.related?.map((r) => ({
1314
+ correlation: r.correlation,
1315
+ subquery: normalizeShape(r.subquery),
1316
+ })),
1317
+ orderBy: ast.orderBy,
1318
+ limit: ast.limit !== undefined,
1319
+ start: ast.start
1320
+ ? { keys: Object.keys(ast.start.row).sort(), exclusive: ast.start.exclusive }
1321
+ : undefined,
1322
+ aggregate: ast.aggregate,
1323
+ groupBy: ast.groupBy,
1324
+ having: ast.having && normalizeCondition(ast.having),
1325
+ one: ast.one,
1326
+ };
1327
+ }
1328
+
1329
+ function normalizeCondition(c: Condition): unknown {
1330
+ switch (c.type) {
1331
+ case "simple":
1332
+ return {
1333
+ type: c.type,
1334
+ op: c.op,
1335
+ left: c.left,
1336
+ right: c.right.type === "literal" ? { type: "literal" } : c.right,
1337
+ };
1338
+ case "and":
1339
+ case "or":
1340
+ return { type: c.type, conditions: c.conditions.map(normalizeCondition) };
1341
+ case "correlatedSubquery":
1342
+ return {
1343
+ type: c.type,
1344
+ op: c.op,
1345
+ related: {
1346
+ correlation: c.related.correlation,
1347
+ subquery: normalizeShape(c.related.subquery),
1348
+ },
1349
+ };
1350
+ }
1351
+ }
1352
+
363
1353
  export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
364
1354
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
365
1355
  const mode = opts.mode ?? "normalized";
1356
+ // The mutation seam — defaulting to the daemon's co-transactional lmid stamp (unchanged behavior).
1357
+ const backend = opts.backend ?? daemonBackend(opts.daemon);
1358
+ // Schema-derived render metadata for logical mutator writes; `{}` when no schema is configured (a
1359
+ // logical op then throws loudly — the tx never silently drops a write). Each backend renders in its
1360
+ // own dialect (`backend.dialect`: daemon→sqlite, postgres→postgres).
1361
+ const renderIndex: RenderIndex = opts.schema ? buildRenderIndex(opts.schema) : {};
366
1362
  // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
367
1363
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
368
1364
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
@@ -436,31 +1432,34 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
436
1432
  const pushMutation = async (input: PushMutationRequest<User>): Promise<PushMutationResponse> => {
437
1433
  const context: ApiContext<User> = { user: input.user, request: input.request };
438
1434
  const mutator = opts.mutators?.[input.envelope.name];
439
- if (!mutator) return reject(opts.daemon, input.envelope, `unknown mutator: ${input.envelope.name}`);
440
- let sql: Pick<SqlTxn, "statements" | "idempotencyKey">;
1435
+ // PRE-FLIGHT rejections — no txn, no data, `lmid` alone (the queue must still drain).
1436
+ if (!mutator) return reject(backend, input.envelope, `unknown mutator: ${input.envelope.name}`);
441
1437
  try {
442
1438
  await assertAuthorized(opts.authorizeMutation, {
443
1439
  user: input.user,
444
1440
  envelope: input.envelope,
445
1441
  context,
446
1442
  });
447
- const tx = new CollectingSqlMutationTx();
448
- const result = await mutator(tx, input.envelope.args as never, {
449
- user: input.user,
450
- envelope: input.envelope,
451
- daemon: opts.daemon,
452
- request: input.request,
453
- });
454
- sql = mutationResultToSqlTxn(result, tx);
455
1443
  } catch (err) {
456
- return reject(opts.daemon, input.envelope, String((err as Error)?.message ?? err));
1444
+ return reject(backend, input.envelope, errMessage(err));
457
1445
  }
458
- const output = await opts.daemon.executeSqlTxn({
459
- ...sql,
460
- clientID: input.envelope.clientID,
461
- mid: input.envelope.mid,
1446
+ // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1447
+ // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1448
+ const outcome = await backend.runMutation({
1449
+ envelope: input.envelope,
1450
+ render: renderIndex,
1451
+ run: async (tx) => {
1452
+ const result = await mutator(tx, input.envelope.args as never, {
1453
+ user: input.user,
1454
+ envelope: input.envelope,
1455
+ daemon: opts.daemon,
1456
+ request: input.request,
1457
+ });
1458
+ applyResultToTx(result, tx);
1459
+ },
462
1460
  });
463
- return { accepted: true, rejected: false, output };
1461
+ if (outcome.accepted) return { accepted: true, rejected: false, output: outcome.output };
1462
+ return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
464
1463
  };
465
1464
 
466
1465
  const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
@@ -518,6 +1517,30 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
518
1517
  }
519
1518
  };
520
1519
 
1520
+ // The room write-authority gate (§5.3.1): endpoints are disabled until the app
1521
+ // opts in with `authorizeRoom` — hosting an authority is never a default.
1522
+ const roomGate = async (context: ApiContext<User>): Promise<void> => {
1523
+ if (!opts.authorizeRoom) {
1524
+ throw new RindleApiError("forbidden", "room authority not configured", 403);
1525
+ }
1526
+ await assertAuthorized(opts.authorizeRoom, context);
1527
+ };
1528
+
1529
+ // The store's verdict rides specific statuses + body shapes (fence / conflict /
1530
+ // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
1531
+ const daemonVerdict = (e: unknown): RoomHostResponse => {
1532
+ if (e instanceof DaemonHttpError) {
1533
+ let body: unknown;
1534
+ try {
1535
+ body = JSON.parse(e.body);
1536
+ } catch {
1537
+ body = { error: e.body };
1538
+ }
1539
+ return { status: e.status, body };
1540
+ }
1541
+ throw e;
1542
+ };
1543
+
521
1544
  return {
522
1545
  routes,
523
1546
  createQueryLease,
@@ -525,6 +1548,46 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
525
1548
  assertPins,
526
1549
  pushMutation,
527
1550
  pushMutations,
1551
+ handleApplyRowChangeTxnJson: async (body, context) => {
1552
+ await roomGate(context);
1553
+ const msg = parseObject(body, "row-change txn");
1554
+ try {
1555
+ const out = await opts.daemon.applyRowChangeTxn(msg as unknown as RowChangeTxn);
1556
+ return { status: 200, body: out };
1557
+ } catch (e) {
1558
+ return daemonVerdict(e);
1559
+ }
1560
+ },
1561
+ handleClaimRoomEpochJson: async (body, context) => {
1562
+ await roomGate(context);
1563
+ const msg = parseObject(body, "claim-room-epoch request");
1564
+ const doc = parseString(msg.doc, "doc");
1565
+ const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1566
+ if (!claim) {
1567
+ throw new Error("the configured daemon client does not implement claimRoomEpoch");
1568
+ }
1569
+ try {
1570
+ return { status: 200, body: await claim({ doc }) };
1571
+ } catch (e) {
1572
+ return daemonVerdict(e);
1573
+ }
1574
+ },
1575
+ handleRoomLmidsJson: async (body, context) => {
1576
+ await roomGate(context);
1577
+ const msg = parseObject(body, "room-lmids request");
1578
+ if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
1579
+ throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
1580
+ }
1581
+ const lmids = opts.daemon.roomLmids?.bind(opts.daemon);
1582
+ if (!lmids) {
1583
+ throw new Error("the configured daemon client does not implement roomLmids");
1584
+ }
1585
+ try {
1586
+ return { status: 200, body: await lmids({ clients: msg.clients as string[] }) };
1587
+ } catch (e) {
1588
+ return daemonVerdict(e);
1589
+ }
1590
+ },
528
1591
  handleQueryJson: (body, context) => {
529
1592
  const msg = parseObject(body, "query request");
530
1593
  return createQueryLease({
@@ -560,24 +1623,53 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
560
1623
  };
561
1624
  }
562
1625
 
563
- class CollectingSqlMutationTx implements SqlMutationTx {
564
- private readonly stmts: SqlStatement[] = [];
565
-
566
- get statements(): readonly SqlStatement[] {
567
- return this.stmts;
568
- }
1626
+ /** Run a SHARED generator mutator (the SAME body the client predicts) against a live server
1627
+ * transaction (MUTATORS-ISOMORPHIC): bind the tier-agnostic {@link isoTx} factory and drive it —
1628
+ * each yielded logical op renders + runs against `tx` (dialect SQL, per backend), each `tx.row`
1629
+ * suspends for read-your-writes, and `tx.all` fans out. A server mutator uses this to delegate its
1630
+ * write body after parsing untrusted args and applying its server-only authority (principal, policy).
1631
+ * A mutator-body throw remains a business rejection; a DB failure propagates as infra. */
1632
+ export function runSharedMutation<Args, Ctx extends MutatorCtx>(
1633
+ mutator: SharedMutator<Args, Ctx>,
1634
+ args: Args,
1635
+ ctx: Ctx,
1636
+ tx: ServerMutationTx,
1637
+ ): Promise<void> {
1638
+ return driveMutationAsync(mutator(isoTx, args, ctx), {
1639
+ apply: (op) => applyOpToServerTx(tx, op),
1640
+ read: (table, pk) => tx.row(table, pk),
1641
+ });
1642
+ }
569
1643
 
570
- exec(sql: string, params: WireValue[] = []): void {
571
- this.stmts.push({ sql, params });
1644
+ /** Run one logical {@link MutationOp} (yielded by a shared generator mutator) against the live server
1645
+ * write surface — the SAME async methods a plain async mutator calls (they render dialect SQL and
1646
+ * execute/accumulate per backend). */
1647
+ function applyOpToServerTx(tx: ServerWriteTx, op: MutationOp): Promise<void> {
1648
+ switch (op.kind) {
1649
+ case "insert":
1650
+ return tx.insert(op.table, op.row);
1651
+ case "upsert":
1652
+ return tx.upsert(op.table, op.row);
1653
+ case "insertIgnore":
1654
+ return tx.insertIgnore(op.table, op.row);
1655
+ case "update":
1656
+ return tx.update(op.table, op.row);
1657
+ case "delete":
1658
+ return tx.delete(op.table, op.pk);
572
1659
  }
573
1660
  }
574
1661
 
575
- function mutationResultToSqlTxn(result: ApiMutatorResult, tx: SqlMutationTx): Pick<SqlTxn, "statements" | "idempotencyKey"> {
576
- if (Array.isArray(result)) return { statements: result };
577
- if (result && typeof result === "object" && "statements" in result) {
578
- return { statements: result.statements, idempotencyKey: result.idempotencyKey };
1662
+ /** Feed a mutator's RETURNED result (the alternative to calling `tx.exec`/logical ops directly) into
1663
+ * the backend tx: a returned `SqlStatement[]` / `SqlTxn` is exec'd onto `tx`, and a carried
1664
+ * `idempotencyKey` is stashed (the daemon backend honors it; PG ignores it). A `void` return is a
1665
+ * no-op the mutator already drove the tx. Preserves the pre-existing return-style contract. */
1666
+ function applyResultToTx(result: ApiMutatorResult, tx: ServerMutationTx): void {
1667
+ if (!result) return;
1668
+ const statements = Array.isArray(result) ? result : result.statements;
1669
+ for (const s of statements) tx.exec(s.sql, s.params);
1670
+ if (!Array.isArray(result) && result.idempotencyKey !== undefined) {
1671
+ (tx as { idempotencyKey?: string }).idempotencyKey = result.idempotencyKey;
579
1672
  }
580
- return { statements: [...tx.statements] };
581
1673
  }
582
1674
 
583
1675
  async function assertAuthorized<T>(authorizer: Authorizer<T> | undefined, input: T): Promise<void> {
@@ -629,11 +1721,11 @@ function errMessage(reason: unknown): string {
629
1721
  }
630
1722
 
631
1723
  async function reject(
632
- daemon: RindleDaemonClient,
1724
+ backend: MutationBackend,
633
1725
  envelope: MutationEnvelope,
634
1726
  reason: string,
635
1727
  ): Promise<PushMutationResponse> {
636
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
1728
+ const output = await backend.reject({ envelope, reason });
637
1729
  return { accepted: false, rejected: true, reason, output };
638
1730
  }
639
1731