@rindle/optimistic 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/backend.ts CHANGED
@@ -38,13 +38,28 @@
38
38
  // `Store`/`ArrayView` are untouched: this implements `Backend`, so
39
39
  // `new Store(schema, new OptimisticBackend(...))` reuses the whole machinery.
40
40
 
41
- import { CLIENT_MUTATIONS_SCHEMA, LMID_QUERY_NAME, localTableNames, normalizedTableSchemas, tableSpec } from "@rindle/client";
41
+ import {
42
+ CLIENT_MUTATIONS_SCHEMA,
43
+ driveMutationSync,
44
+ isGeneratorMutator,
45
+ isoTx,
46
+ LMID_QUERY_NAME,
47
+ localTableNames,
48
+ normalizedTableSchemas,
49
+ tableSpec,
50
+ } from "@rindle/client";
42
51
  import type {
43
52
  Ast,
44
53
  Backend,
54
+ BackendDevObserver,
45
55
  ChangeEvent,
46
56
  ColsMap,
57
+ IsoTx,
58
+ KeyedRow,
47
59
  Mutation,
60
+ MutationGen,
61
+ MutationOp,
62
+ MutatorCtx,
48
63
  NormalizedEvent,
49
64
  NormalizedOp,
50
65
  NormalizedTableSchema,
@@ -61,9 +76,22 @@ import { WasmBackend, type ServerDeltaOp, type WasmWriteTxn } from "@rindle/wasm
61
76
 
62
77
  import { AggOverlay, type ChildOp, collectAggDefs } from "./agg-overlay.ts";
63
78
 
64
- /** A keyed row: column name → cell. The ergonomic shape — column names are validated
65
- * against the schema at runtime, so a typo throws immediately with the valid names. */
66
- export type KeyedRow = Record<string, WireValue>;
79
+ /** A keyed row: column name → cell. The ergonomic shape — column names are validated against the
80
+ * schema at runtime, so a typo throws immediately with the valid names. Re-exported from
81
+ * `@rindle/client` (the leaf both tiers share). */
82
+ export type { KeyedRow };
83
+
84
+ /** What `MutationTx.query` accepts: a query handle whose `.ast()` lowers to the wire
85
+ * {@link Ast} — exactly what the typed query builder (`store.query.<table>…` or
86
+ * `newQueryBuilder(schema).<table>…`) produces (203-MUTATOR-READS-DESIGN.md §9.1). Typed
87
+ * structurally so the `MutationTx` surface need not carry the builder's heavy generics. */
88
+ export type QueryArg = { ast(): Ast };
89
+
90
+ /** A row returned by {@link MutationTx.query}: column name → cell, plus each materialized
91
+ * relationship name → its nested row(s) — an array (a plural relationship), or a single row /
92
+ * `null` (a `.one()` relationship). Recursive (nested rows share the shape). Presented
93
+ * identically to a `view.data` row of the same query (203-MUTATOR-READS-DESIGN.md §5.2). */
94
+ export type QueryResultRow = { [key: string]: WireValue | QueryResultRow | QueryResultRow[] };
67
95
 
68
96
  /** The write handle a client mutator runs against (the client `MutationTx`, §4.2):
69
97
  * reads see the current base + this transaction's own staged writes (§4.1).
@@ -82,8 +110,19 @@ export interface MutationTx {
82
110
  update(table: string, row: KeyedRow): void;
83
111
  /** Insert, or fully replace when the pk already exists (a FULL row, like `insert`). */
84
112
  upsert(table: string, row: KeyedRow): void;
113
+ /** Insert a FULL row, or do nothing if the pk already exists (the isomorphic form of the classic
114
+ * `if (!tx.row(pk)) tx.insert(row)` upsert-if-absent; renders `ON CONFLICT DO NOTHING` server-side). */
115
+ insertIgnore(table: string, row: KeyedRow): void;
85
116
  /** Delete the row identified by the pk columns. A missing row is a NO-OP. */
86
117
  delete(table: string, pk: KeyedRow): void;
118
+ /** Run a one-shot read query (`where`/`orderBy`/`limit`/join) over the state this
119
+ * mutator is mutating — it sees this transaction's own writes-so-far, the same
120
+ * read-your-writes contract as `get`/`row` (§4.1; 203-MUTATOR-READS-DESIGN.md §5.2).
121
+ * Synchronous; returns the matching rows in the query's order, each with its materialized
122
+ * relationship children nested by name (presented identically to a `view.data` row). Pass
123
+ * a query from the typed builder, e.g. `tx.query(q.issue.where("owner", "=", me))`.
124
+ * Refused inside a FOLDED mutator (a reading mutator is non-absorbing, §9.1). */
125
+ query(query: QueryArg): QueryResultRow[];
87
126
  // --- positional (the wire shape) ---
88
127
  get(table: string, pk: WireValue[]): WireValue[] | undefined;
89
128
  add(table: string, row: WireValue[]): void;
@@ -91,9 +130,14 @@ export interface MutationTx {
91
130
  edit(table: string, oldRow: WireValue[], newRow: WireValue[]): void;
92
131
  }
93
132
 
94
- /** A client mutator: optimistic, deterministic, replayable — a pure function of
95
- * `(base, args)` (§5: no clock, no randomness; it is RE-INVOKED on every rebase). */
96
- export type ClientMutator = (tx: MutationTx, args: never) => void;
133
+ /** A client mutator: optimistic, deterministic, replayable — a pure function of `(base, args)` (§5:
134
+ * no clock, no randomness; it is RE-INVOKED on every rebase). Either shape is accepted:
135
+ * - a plain synchronous function `(tx, args) => void` (client-only), OR
136
+ * - a shared GENERATOR `(tx, args, ctx) => MutationGen` (the isomorphic form: the SAME body the API
137
+ * server runs against a live async transaction — MUTATORS-ISOMORPHIC). The driver detects which. */
138
+ export type ClientMutator =
139
+ | ((tx: MutationTx, args: never) => void)
140
+ | ((tx: IsoTx, args: never, ctx: MutatorCtx) => MutationGen);
97
141
 
98
142
  /** The client registry (§4.2) — one of the two registries; the server's authoritative
99
143
  * twin shares names (and possibly code), never the wire. */
@@ -182,6 +226,11 @@ interface BufferedFrame {
182
226
  export interface OptimisticBackendOptions {
183
227
  /** Stable per-client identity for the upstream envelopes (§8.1). */
184
228
  clientID: string;
229
+ /** The acting principal, for a shared (generator) mutator's `ctx.user` — re-read per invoke so a
230
+ * mid-session login is picked up, and stable across a rebase re-invoke (replayable). Plain
231
+ * client-only mutators ignore it. Defaults to the empty string (an app that registers generator
232
+ * mutators must supply this). */
233
+ user?: () => string;
185
234
  /** Buffered-frame ceiling before the §8.5 escape (drop + re-hydrate). */
186
235
  bufferCap?: number;
187
236
  /** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
@@ -255,6 +304,8 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
255
304
  private readonly source: OptimisticSource;
256
305
  private readonly registry: ClientRegistry;
257
306
  private readonly clientID: string;
307
+ /** The acting principal provider for a shared mutator's `ctx.user` (§ shared mutators). */
308
+ private readonly user: () => string;
258
309
  private readonly bufferCap: number;
259
310
  /** Column order + pk indices per table, for the keyed `MutationTx` methods. */
260
311
  private readonly specs: TableSpecs;
@@ -287,6 +338,12 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
287
338
  * pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
288
339
  private readonly overlay = new AggOverlay();
289
340
  private handler: (qid: QueryId, ev: ChangeEvent) => void = () => {};
341
+ /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
342
+ * local engine's `dispatch` brackets so the Store folds every affected view before notifying any
343
+ * subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
344
+ * local engine, so its commit brackets are this backend's commit brackets. */
345
+ private boundaryHandler: (phase: "begin" | "end") => void = () => {};
346
+ private readonly devObservers = new Set<BackendDevObserver>();
290
347
 
291
348
  private pendingMutations: PendingMutation[] = [];
292
349
  private nextMid = 1;
@@ -328,6 +385,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
328
385
  ) {
329
386
  this.local = new WasmBackend(schema);
330
387
  this.local.onEvent((qid, ev) => this.handler(qid, ev));
388
+ // Forward the local engine's commit brackets up to the Store (cross-view-atomic notification):
389
+ // every data delta this backend emits comes from `this.local`, so its commit boundaries are ours.
390
+ this.local.onCommitBoundary((phase) => this.boundaryHandler(phase));
331
391
  this.colCounts = colCountsFromSchema(schema);
332
392
  this.colIndex = colIndexFromSchema(schema);
333
393
  this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
@@ -336,6 +396,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
336
396
  this.source = source;
337
397
  this.registry = registry;
338
398
  this.clientID = opts.clientID;
399
+ this.user = opts.user ?? (() => "");
339
400
  this.bufferCap = opts.bufferCap ?? 1024;
340
401
  this.clock = opts.clock ?? REAL_CLOCK;
341
402
  // Validate each server hello against our OWN typed schema → reject a schema skew
@@ -518,39 +579,86 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
518
579
  this.handler = handler;
519
580
  }
520
581
 
582
+ onCommitBoundary(handler: (phase: "begin" | "end") => void): void {
583
+ this.boundaryHandler = handler;
584
+ }
585
+
521
586
  // --- the named-mutator entry (§9) ----------------------------------------------
522
587
 
588
+ /** Bracket a multi-step optimistic apply as ONE notification commit (cross-view-atomic
589
+ * notification — {@link Backend.onCommitBoundary}). `invoke`/`invokeFolded` apply the prediction
590
+ * and then reconcile the `__agg` head in TWO `this.local` commits, but they are two halves of one
591
+ * logical mutation: a relationship-`count` view and the data view it counts must update together.
592
+ * The Store's `commitDepth` is a counter, so each inner commit's own `begin`/`end` nests under this
593
+ * outer pair and the Store flushes every affected view (data AND count) once, together, at the
594
+ * outer `end` — a subscriber re-reading a sibling view then sees post-commit data, never a torn
595
+ * half. Balanced on throw (the prediction mutator may reject) via the `finally`, so a thrown
596
+ * prediction never wedges the Store in deferred mode. */
597
+ private inOneCommit<T>(apply: () => T): T {
598
+ this.boundaryHandler("begin");
599
+ try {
600
+ return apply();
601
+ } finally {
602
+ this.boundaryHandler("end");
603
+ }
604
+ }
605
+
606
+ /** Run one client mutator against the staged `tx`, accepting BOTH forms (§ shared mutators):
607
+ * a plain sync function runs as-is; a shared GENERATOR is driven synchronously — every yielded
608
+ * write applies to the wasm txn now, every `tx.row` read is resolved against the same staged
609
+ * state (read-your-writes), the SAME body the API server drives asynchronously. `ctx.user` is
610
+ * the acting principal (re-read per invoke, stable across a rebase re-invoke). */
611
+ private runMutator(mutator: ClientMutator, tx: MutationTx, args: unknown): void {
612
+ if (isGeneratorMutator(mutator)) {
613
+ const gen = (mutator as (t: IsoTx, a: never, c: MutatorCtx) => MutationGen)(
614
+ isoTx,
615
+ args as never,
616
+ { user: this.user() },
617
+ );
618
+ driveMutationSync(gen, {
619
+ apply: (op) => applyOpToTx(tx, op),
620
+ read: (table, pk) => tx.row(table, pk),
621
+ });
622
+ } else {
623
+ (mutator as (t: MutationTx, a: never) => void)(tx, args as never);
624
+ }
625
+ }
626
+
523
627
  /** Run the named client mutator optimistically: the prediction applies to the live
524
628
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
525
629
  * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
526
630
  invoke(name: string, args: unknown): number {
527
631
  const mutator = this.registry[name];
528
632
  if (!mutator) throw new Error(`unknown client mutator: ${name}`);
529
- // Apply the prediction FIRST. If the mutator throws (client-side validation, a bad read),
530
- // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
531
- // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
532
- // silently refuses every later mutation from this client (#10).
533
- const touched = new Set<string>();
534
- const ops: ChildOp[] = [];
535
- this.local.writeWith((tx) => {
536
- mutator(trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), args as never);
633
+ // One commit boundary spans the prediction AND the `__agg`-head reconcile below, so their views
634
+ // (data + count) flush together rather than tearing across two engine commits.
635
+ return this.inOneCommit(() => {
636
+ // Apply the prediction FIRST. If the mutator throws (client-side validation, a bad read),
637
+ // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
638
+ // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
639
+ // silently refuses every later mutation from this client (#10).
640
+ const touched = new Set<string>();
641
+ const ops: ChildOp[] = [];
642
+ this.local.writeWith((tx) => {
643
+ this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), args);
644
+ });
645
+ // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
646
+ // this write does, so wire order == local-apply order for any pair that can observe each other
647
+ // (a read-dependent write reading a folded cell sees the same value optimistically and on the
648
+ // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
649
+ this.drainOverlapping(touched);
650
+ const mid = this.nextMid++;
651
+ this.pendingMutations.push({ mid, name, args, touched });
652
+ // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
653
+ // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
654
+ // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
655
+ // touched group's head as the absolute `server_base ⊕ delta`.
656
+ for (const op of ops) this.overlay.observe(op);
657
+ this.reconcileAggHead();
658
+ this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
659
+ void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
660
+ return mid;
537
661
  });
538
- // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
539
- // this write does, so wire order == local-apply order for any pair that can observe each other
540
- // (a read-dependent write reading a folded cell sees the same value optimistically and on the
541
- // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
542
- this.drainOverlapping(touched);
543
- const mid = this.nextMid++;
544
- this.pendingMutations.push({ mid, name, args, touched });
545
- // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
546
- // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
547
- // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
548
- // touched group's head as the absolute `server_base ⊕ delta`.
549
- for (const op of ops) this.overlay.observe(op);
550
- this.reconcileAggHead();
551
- this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
552
- void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
553
- return mid;
554
662
  }
555
663
 
556
664
  /** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
@@ -561,64 +669,68 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
561
669
  const mutator = this.registry[name];
562
670
  if (!mutator) throw new Error(`unknown client mutator: ${name}`);
563
671
  const foldKey = `${name}\0${stableJson(opts.key)}`;
564
- // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
565
- // compute its write is non-absorbing and refused. A throw discards the staged write (clean
566
- // no-op) and consumes no mid — exactly `invoke`'s guarantee.
567
- const touched = new Set<string>();
568
- const ops: ChildOp[] = [];
569
- try {
570
- this.local.writeWith((tx) => {
571
- mutator(trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops), true), args as never);
572
- });
573
- } catch (e) {
574
- if (e instanceof FoldReadError) {
575
- throw new Error(
576
- `cannot fold "${name}": it reads state via tx.get/tx.row, so it is not absorbing — folded mutators must be last-writer-wins (FOLDED-MUTATIONS-DESIGN §5)`,
577
- );
672
+ // One commit boundary spans the prediction AND the `__agg`-head reconcile (see {@link inOneCommit}),
673
+ // so a folded mutation's list view and count view flush together, never torn across two commits.
674
+ return this.inOneCommit(() => {
675
+ // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
676
+ // compute its write is non-absorbing and refused. A throw discards the staged write (clean
677
+ // no-op) and consumes no mid — exactly `invoke`'s guarantee.
678
+ const touched = new Set<string>();
679
+ const ops: ChildOp[] = [];
680
+ try {
681
+ this.local.writeWith((tx) => {
682
+ this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops), true), args);
683
+ });
684
+ } catch (e) {
685
+ if (e instanceof FoldReadError) {
686
+ throw new Error(
687
+ `cannot fold "${name}": it reads state via tx.get/tx.row, so it is not absorbing — folded mutators must be last-writer-wins (FOLDED-MUTATIONS-DESIGN §5)`,
688
+ );
689
+ }
690
+ throw e;
578
691
  }
579
- throw e;
580
- }
581
- for (const op of ops) this.overlay.observe(op);
582
- this.reconcileAggHead();
583
-
584
- const now = this.clock.now();
585
- let f = this.folds.get(foldKey);
586
- if (f) {
587
- // Overwrite the single entry in place the pending stack does NOT grow (§1 #2). The head
588
- // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
589
- // only the LATEST args, which is what a rebase re-derives from and what the flush ships.
590
- f.entry.args = args;
591
- f.entry.touched = touched;
592
- f.args = args;
593
- this.clock.clearTimeout(f.timer);
594
- } else {
595
- const entry: PendingMutation = { mid: null, name, args, touched };
596
- this.pendingMutations.push(entry);
597
- let resolveMid!: (mid: number) => void;
598
- const midPromise = new Promise<number>((res) => (resolveMid = res));
599
- f = {
600
- entry,
601
- args,
602
- timer: undefined,
603
- firstAt: now,
604
- debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
605
- maxWaitMs: opts.maxWaitMs,
606
- deferAcrossWrites: opts.deferAcrossWrites ?? false,
607
- midPromise,
608
- resolveMid,
609
- };
610
- this.folds.set(foldKey, f);
611
- }
612
- // (Re)arm the trailing debounce unless the maxWaitMs cap is already due (a never-idle drag
613
- // still persists periodically, §3/#4), in which case flush now instead of re-arming.
614
- if (f.maxWaitMs !== undefined && now - f.firstAt >= f.maxWaitMs) {
615
- this.flushFold(foldKey);
616
- } else {
617
- f.timer = this.clock.setTimeout(() => this.flushFold(foldKey), f.debounceMs);
618
- }
619
- this.refreshPending();
620
- const handle: FoldHandle = { flush: () => this.flushFold(foldKey), mid: f.midPromise };
621
- return handle;
692
+ for (const op of ops) this.overlay.observe(op);
693
+ this.reconcileAggHead();
694
+
695
+ const now = this.clock.now();
696
+ let f = this.folds.get(foldKey);
697
+ if (f) {
698
+ // Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
699
+ // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
700
+ // only the LATEST args, which is what a rebase re-derives from and what the flush ships.
701
+ f.entry.args = args;
702
+ f.entry.touched = touched;
703
+ f.args = args;
704
+ this.clock.clearTimeout(f.timer);
705
+ } else {
706
+ const entry: PendingMutation = { mid: null, name, args, touched };
707
+ this.pendingMutations.push(entry);
708
+ let resolveMid!: (mid: number) => void;
709
+ const midPromise = new Promise<number>((res) => (resolveMid = res));
710
+ f = {
711
+ entry,
712
+ args,
713
+ timer: undefined,
714
+ firstAt: now,
715
+ debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
716
+ maxWaitMs: opts.maxWaitMs,
717
+ deferAcrossWrites: opts.deferAcrossWrites ?? false,
718
+ midPromise,
719
+ resolveMid,
720
+ };
721
+ this.folds.set(foldKey, f);
722
+ }
723
+ // (Re)arm the trailing debounce — unless the maxWaitMs cap is already due (a never-idle drag
724
+ // still persists periodically, §3/#4), in which case flush now instead of re-arming.
725
+ if (f.maxWaitMs !== undefined && now - f.firstAt >= f.maxWaitMs) {
726
+ this.flushFold(foldKey);
727
+ } else {
728
+ f.timer = this.clock.setTimeout(() => this.flushFold(foldKey), f.debounceMs);
729
+ }
730
+ this.refreshPending();
731
+ const handle: FoldHandle = { flush: () => this.flushFold(foldKey), mid: f.midPromise };
732
+ return handle;
733
+ });
622
734
  }
623
735
 
624
736
  /** Flush-on-enqueue (§4.2): for each outstanding fold whose touched tables overlap `tables`,
@@ -701,6 +813,13 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
701
813
  this.resultTypeHandler = handler;
702
814
  }
703
815
 
816
+ __attachDevtoolsServerDeltas(observer: BackendDevObserver): () => void {
817
+ this.devObservers.add(observer);
818
+ return () => {
819
+ this.devObservers.delete(observer);
820
+ };
821
+ }
822
+
704
823
  // --- the pending AXIS (§7.2): orthogonal to ResultType -------------------------------
705
824
 
706
825
  /** Whether any pending mutation (folded or not) touches this query's tables — "is a prediction
@@ -777,6 +896,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
777
896
  // --- the downstream stream (§8.5: buffer, then release coherently) ---------------
778
897
 
779
898
  private onNormalized(qid: QueryId, ev: NormalizedEvent): void {
899
+ if (qid !== LMID_QID) this.emitServerDelta(qid, ev);
780
900
  if (ev.type === "hello") {
781
901
  // A hello is a (re)subscribe = a NEW epoch. The column map below is mutated eagerly, but
782
902
  // data frames are cv-buffered and drained later (onProgress). So any frame still buffered
@@ -813,6 +933,21 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
813
933
  if (this.buffer.length > this.bufferCap) this.overflow();
814
934
  }
815
935
 
936
+ private emitServerDelta(sourceQid: QueryId, ev: NormalizedEvent): void {
937
+ if (!this.devObservers.size) return;
938
+ for (const qid of this.localQidsForSource(sourceQid)) {
939
+ for (const o of this.devObservers) o.onServerDelta?.(qid, { format: "normalized", event: ev });
940
+ }
941
+ }
942
+
943
+ private localQidsForSource(sourceQid: QueryId): QueryId[] {
944
+ const key = this.sourceToRemote.get(sourceQid);
945
+ const sub = key ? this.remoteSubs.get(key) : undefined;
946
+ if (!sub) return [sourceQid];
947
+ const localQids = [...sub.localQids.keys()];
948
+ return localQids.length ? localQids : [sourceQid];
949
+ }
950
+
816
951
  private onProgress(frame: ProgressFrame): void {
817
952
  // (1) Take every buffered frame at cv ≤ cvMin, in (cv, arrival) order, and fold it:
818
953
  // lmid system-query frames advance `confirmedLmid`; data frames fold through the
@@ -917,7 +1052,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
917
1052
  const ops: ChildOp[] = [];
918
1053
  try {
919
1054
  this.local.writeWith((tx) => {
920
- this.registry[p.name](trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), p.args as never);
1055
+ this.runMutator(this.registry[p.name], trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), p.args);
921
1056
  });
922
1057
  } catch {
923
1058
  // A re-invocation threw — e.g. a read-dependent mutator whose base row the server
@@ -1138,6 +1273,24 @@ function colIndexFromSchema<S extends ColsMap>(schema: Schema<S>): Record<string
1138
1273
  * — a folded mutator that reads state to compute its write is non-absorbing and refused. The keyed
1139
1274
  * writers (`update`/`upsert`/`delete`) still read internally to preserve unspecified columns; that
1140
1275
  * is column-preservation, not value-derivation, so it stays allowed. */
1276
+ /** Apply one logical {@link MutationOp} (yielded by a shared generator mutator) onto the client's
1277
+ * keyed {@link MutationTx} — the same methods a plain client mutator calls directly. Column
1278
+ * validation, touched-table tracking, and op collection all happen inside those methods. */
1279
+ function applyOpToTx(tx: MutationTx, op: MutationOp): void {
1280
+ switch (op.kind) {
1281
+ case "insert":
1282
+ return tx.insert(op.table, op.row);
1283
+ case "upsert":
1284
+ return tx.upsert(op.table, op.row);
1285
+ case "insertIgnore":
1286
+ return tx.insertIgnore(op.table, op.row);
1287
+ case "update":
1288
+ return tx.update(op.table, op.row);
1289
+ case "delete":
1290
+ return tx.delete(op.table, op.pk);
1291
+ }
1292
+ }
1293
+
1141
1294
  function trackingTx(
1142
1295
  tx: WasmWriteTxn,
1143
1296
  touched: Set<string>,
@@ -1225,8 +1378,20 @@ function trackingTx(
1225
1378
  throw new FoldReadError();
1226
1379
  };
1227
1380
 
1381
+ // One-shot query (203 §5.2): lower the builder to an AST, refuse any local-only table it
1382
+ // reads (M1 — same guard as `rawGet`), and run it over the engine's read-cache fork. The
1383
+ // wasm engine returns the rows already keyed by column name with their materialized
1384
+ // relationship children nested by name (`marshal::caught_node_to_js`), in the query's order —
1385
+ // identical in shape to `view.data` — so this is a pass-through.
1386
+ const runQuery = (q: QueryArg): QueryResultRow[] => {
1387
+ const ast = q.ast();
1388
+ for (const t of collectTables(ast)) assertNotLocal(t, "read");
1389
+ return tx.query(ast) as QueryResultRow[];
1390
+ };
1391
+
1228
1392
  return {
1229
1393
  get: trapReads ? trapped : rawGet,
1394
+ query: trapReads ? trapped : runQuery,
1230
1395
  add,
1231
1396
  remove,
1232
1397
  edit,
@@ -1255,6 +1420,10 @@ function trackingTx(
1255
1420
  if (current) edit(table, current, toCells(table, row));
1256
1421
  else add(table, toCells(table, row));
1257
1422
  },
1423
+ insertIgnore: (table, row) => {
1424
+ checkColumns(table, row, true);
1425
+ if (!rawGet(table, pkCells(table, row))) add(table, toCells(table, row));
1426
+ },
1258
1427
  delete: (table, pk) => {
1259
1428
  checkColumns(table, pk, false);
1260
1429
  const current = rawGet(table, pkCells(table, pk));
package/src/client-id.ts CHANGED
@@ -39,6 +39,22 @@ export function resetTabInstanceForTests(): void {
39
39
  tabInstance = 0;
40
40
  }
41
41
 
42
+ /** Clear the persisted client identity so the next page load starts a fresh mid/lmid stream.
43
+ * Intended for dev recovery after out-of-band server state loss, not for normal operation. */
44
+ export function resetStableClientID(): void {
45
+ try {
46
+ globalThis.localStorage?.removeItem(CLIENT_ID_KEY);
47
+ } catch {
48
+ // Storage can throw in private modes or test shims; best-effort reset.
49
+ }
50
+ try {
51
+ globalThis.sessionStorage?.removeItem(TAB_ID_KEY);
52
+ } catch {
53
+ // Storage can throw in private modes or test shims; best-effort reset.
54
+ }
55
+ tabInstance = 0;
56
+ }
57
+
42
58
  /** The connection's stable clientID. A per-origin base (localStorage) keeps one logical identity
43
59
  * across reloads; a per-tab suffix (sessionStorage) gives each tab its OWN mid/lmid stream; a
44
60
  * per-load instance suffix keeps two clients constructed in ONE tab from sharing that stream. Falls
package/src/client.ts CHANGED
@@ -20,7 +20,7 @@ import { initWasm } from "@rindle/wasm";
20
20
 
21
21
  import type { OptimisticBackend } from "./backend.ts";
22
22
  import type { ClientRegistry, MutationTx } from "./backend.ts";
23
- import { stableClientID } from "./client-id.ts";
23
+ import { resetStableClientID, stableClientID } from "./client-id.ts";
24
24
  import { createOptimisticStore, type MutateFn } from "./index.ts";
25
25
  import type { Store } from "@rindle/client";
26
26
 
@@ -34,6 +34,10 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
34
34
  schema: Schema<S>;
35
35
  /** The PREDICTED mutators (the API server holds the authoritative twins by name). */
36
36
  mutators: R;
37
+ /** The acting principal for a shared (generator) mutator's `ctx.user` — the local identity the
38
+ * optimistic prediction writes under (the server injects its OWN authenticated user for the
39
+ * authoritative run). Re-read per invoke. Only needed when `mutators` are shared generators. */
40
+ user?: () => string;
37
41
  /** The app API server: named queries resolve to leases here, mutations push here. */
38
42
  api: {
39
43
  url: string;
@@ -58,6 +62,13 @@ export interface RindleClientOptions<S extends ColsMap, R extends ClientRegistry
58
62
  /** A policy rejection's reason (the prediction's snap-back rides the lmid release). */
59
63
  onRejected?: (envelope: MutationEnvelope, reason: string) => void;
60
64
  queue?: { maxBatch?: number; retryDelayMs?: (attempt: number) => number };
65
+ /** Development-only recovery knobs. Keep off in production: a mutation gap means state loss
66
+ * or two writers sharing a clientID, and should be investigated. */
67
+ dev?: {
68
+ /** On a mutation-gap response, clear the persisted clientID and hard reload the page. This
69
+ * recovers from dev DB wipes while making the reset visible to the developer. */
70
+ resetOnMutationGap?: boolean;
71
+ };
61
72
  }
62
73
 
63
74
  export interface RindleClient<S extends ColsMap, R extends ClientRegistry> {
@@ -90,8 +101,9 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
90
101
  headers: { "content-type": "application/json", ...extra },
91
102
  body: JSON.stringify(payload),
92
103
  });
93
- if (!res.ok) throw new Error(`rindle api ${path} failed: ${res.status} ${await res.text()}`);
94
- return res.json();
104
+ const text = await res.text();
105
+ if (!res.ok) throw new RindleApiHttpError(path, res.status, text);
106
+ return text ? JSON.parse(text) : undefined;
95
107
  };
96
108
 
97
109
  // Reads-leg connection: a fixed transport (tests/in-process) or a replaceable connection built
@@ -116,13 +128,23 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
116
128
  retryDelayMs: opts.queue?.retryDelayMs,
117
129
  onRejected: opts.onRejected,
118
130
  send: async (envelopes) => {
119
- const outcomes = (await post(routes.mutate, { envelopes })) as Array<{ accepted: boolean; reason?: string }>;
120
- return outcomes.map((o): PushOutcome => ({ accepted: o.accepted, reason: o.reason }));
131
+ try {
132
+ const outcomes = (await post(routes.mutate, { envelopes })) as Array<{ accepted: boolean; reason?: string }>;
133
+ return outcomes.map((o): PushOutcome => ({ accepted: o.accepted, reason: o.reason }));
134
+ } catch (err) {
135
+ if (opts.dev?.resetOnMutationGap && reloadAfterMutationGap(err)) {
136
+ return new Promise<never>(() => {});
137
+ }
138
+ throw err;
139
+ }
121
140
  },
122
141
  }),
123
142
  });
124
143
 
125
- const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, { clientID });
144
+ const { store, backend, mutate } = createOptimisticStore(opts.schema, source, opts.mutators, {
145
+ clientID,
146
+ user: opts.user,
147
+ });
126
148
 
127
149
  // Drain folds before the tab goes away (FOLDED-MUTATIONS-DESIGN §0.1/§3): a fold deliberately
128
150
  // holds its server write through the debounce window, so an unclean navigation would otherwise
@@ -154,3 +176,41 @@ export async function createRindleClient<S extends ColsMap, R extends ClientRegi
154
176
 
155
177
  export type { ClientRegistry, MutationTx };
156
178
  export type { MutateFn } from "./index.ts";
179
+
180
+ class RindleApiHttpError extends Error {
181
+ readonly path: string;
182
+ readonly status: number;
183
+ readonly body: string;
184
+
185
+ constructor(path: string, status: number, body: string) {
186
+ super(`rindle api ${path} failed: ${status}${body ? ` ${body}` : ""}`);
187
+ this.name = "RindleApiHttpError";
188
+ this.path = path;
189
+ this.status = status;
190
+ this.body = body;
191
+ }
192
+ }
193
+
194
+ const MUTATION_GAP_RE = /\bmutation(?: id)? gap\b/i;
195
+
196
+ function reloadAfterMutationGap(err: unknown): boolean {
197
+ const text =
198
+ err instanceof RindleApiHttpError
199
+ ? `${err.status} ${err.body} ${err.message}`
200
+ : String((err as Error)?.message ?? err);
201
+ if (!MUTATION_GAP_RE.test(text)) return false;
202
+
203
+ resetStableClientID();
204
+ console.error(
205
+ "[rindle] mutation gap detected; cleared the dev client id and reloading so this tab starts a fresh mutation stream.",
206
+ err,
207
+ );
208
+ const loc = (globalThis as unknown as { location?: { reload?: () => void } }).location;
209
+ if (typeof loc?.reload !== "function") return false;
210
+ try {
211
+ loc.reload();
212
+ return true;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
package/src/index.ts CHANGED
@@ -34,14 +34,20 @@ export type {
34
34
  OptimisticBackendOptions,
35
35
  OptimisticInspect,
36
36
  PendingInspect,
37
+ QueryArg,
38
+ QueryResultRow,
37
39
  ResultType,
38
40
  } from "./backend.ts";
39
41
  export type { MutationEnvelope, OptimisticSource, ProgressFrame } from "@rindle/client";
42
+ // The shared (generator) mutator seam — a registry may hold plain client mutators OR these isomorphic
43
+ // generators (the SAME body the API server runs); re-exported here so an app registers from one import.
44
+ export { isoTx } from "@rindle/client";
45
+ export type { IsoTx, MutationGen, MutatorCtx, SharedMutator, YieldEffect } from "@rindle/client";
40
46
 
41
47
  // The one-call client for the daemon/serverless topology (API server + daemon ws).
42
48
  export { createRindleClient } from "./client.ts";
43
49
  export type { RindleClient, RindleClientOptions } from "./client.ts";
44
- export { stableClientID } from "./client-id.ts";
50
+ export { resetStableClientID, stableClientID } from "./client-id.ts";
45
51
 
46
52
  /** A {@link Store} over an {@link OptimisticBackend}, plus the named-mutator entry
47
53
  * (`mutate.createIssue(args)` — the §9 dream shape) and the §6 lifecycle surface. */