@ultimat3/realtime 20.2.0 → 21.0.0

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.
Files changed (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
@@ -14,6 +14,7 @@ import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
14
14
  import { LiveRowUnidentifiedError } from './errors';
15
15
  import { isRow, type JsonValue, type Row } from './json';
16
16
  import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
17
+ import { liveRecords } from './live-record-type';
17
18
  import { type IncrementalMatcher, matcherFor, type Projection } from './matcher-bridge';
18
19
  import { authorizeWithPolicy, visibleWithPolicy } from './policy-gate';
19
20
 
@@ -47,6 +48,8 @@ interface SharedWindow {
47
48
  readonly matcher: IncrementalMatcher;
48
49
  /** The compiled shape's root entity — the client's identity scope for every row of this read. */
49
50
  readonly rowEntity: string;
51
+ /** Its projection's record key; `null` for a plain table keyed by `id`. */
52
+ readonly rowKey: ((row: Row) => string) | null;
50
53
  read(): Promise<readonly Row[]>;
51
54
  }
52
55
 
@@ -109,11 +112,14 @@ export function liveQueryDefinition(
109
112
  // a second subject-less copy — which is what this did — paid for the parse and the `sql()`
110
113
  // twice per query id and left two descriptions of one read that agreed only by luck.
111
114
  const projection = learnProjection();
115
+ const records = liveRecords(live.shape.entity);
112
116
  const built: SharedWindow = {
113
117
  matcher: matcherFor(live, projection.read),
114
- // `assertMatchable` already refused a shape without one, so this is the entity the matcher
115
- // patches rows of — the same name `ChangeEvent.entity` and `tx.<table>` use.
116
- rowEntity: live.shape.entity,
118
+ // `assertMatchable` already refused a shape without one. The shape names the TABLE (what a
119
+ // `ChangeEvent` carries); the client store keys records by ENTITY name, so the snapshot tells
120
+ // it the record type, and every frame carries the key the entity's projection renders.
121
+ rowEntity: records.type,
122
+ rowKey: records.key,
117
123
  read: async () => {
118
124
  const rows = rowsOf(name, await live.execute());
119
125
  projection.teach(rows);
@@ -142,6 +148,7 @@ export function liveQueryDefinition(
142
148
  // Read off the same resolved window as the matcher, so the scope the client keys rows under and
143
149
  // the entity the matcher patches them from can never be two different names.
144
150
  rowEntity: (input) => windows.get(queryHash(name, input))?.rowEntity ?? null,
151
+ rowKey: (input) => windows.get(queryHash(name, input))?.rowKey ?? null,
145
152
  // The two per-subscriber gates, both through the package's one authz seam. Neither result is
146
153
  // memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
147
154
  // delivery, and there is no key here an actor could share with another actor.
@@ -44,8 +44,10 @@ export async function fanoutChange(
44
44
  // that arrived behind the snapshot that already included it — rewound every subscriber's cursor
45
45
  // to it and asked them to fold state they had already folded over newer rows.
46
46
  if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
47
- const result = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
48
- if (!result) return { sent: 0, stale: 0 };
47
+ const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
48
+ if (!bridged) return { sent: 0, stale: 0 };
49
+ // Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
50
+ const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
49
51
  entry.lsn = change.lsn;
50
52
  entry.rows = applyToWindow(entry.rows, result.patches);
51
53
  // The window lost its tail, so what it holds is a guess — the next delivery re-reads it rather
@@ -138,7 +140,11 @@ async function resnapshot(
138
140
  return true;
139
141
  }
140
142
 
141
- /** The one place a snapshot frame is built, so the identity scope cannot be told to one caller only. */
143
+ /**
144
+ * The one place a snapshot frame is built, so the identity scope and the record keys cannot be
145
+ * told to one caller only. `keys` rides only when some key differs from its row's `id` — an entity
146
+ * keyed by `id` sends the frame it always sent.
147
+ */
142
148
  export function snapshotFrame(
143
149
  entry: QueryEntry,
144
150
  sid: string,
@@ -146,5 +152,25 @@ export function snapshotFrame(
146
152
  cursor: LiveCursor,
147
153
  ): Frame {
148
154
  const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
149
- return entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
155
+ const scoped = entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
156
+ const keyOf = entry.rowKey;
157
+ if (keyOf === null) return scoped;
158
+ const keys = rows.map((row) => keyOf(row));
159
+ return keys.every((key, index) => key === rows[index]?.id) ? scoped : { ...scoped, keys };
160
+ }
161
+
162
+ /**
163
+ * A patch's record key, from the change's WHOLE row — an update patch carries only the changed
164
+ * columns, and a key needs every primary-key column. Only stamped where it differs from `id`.
165
+ */
166
+ function keyPatches(
167
+ entry: QueryEntry,
168
+ change: ChangeEvent,
169
+ patches: readonly RowPatch[],
170
+ ): readonly RowPatch[] {
171
+ const keyOf = entry.rowKey;
172
+ const whole = change.after ?? change.before;
173
+ if (keyOf === null || whole === null) return patches;
174
+ const key = keyOf(whole);
175
+ return patches.map((patch) => (key === patch.id ? patch : { ...patch, key }));
150
176
  }
@@ -0,0 +1,19 @@
1
+ // Which record type a live query's rows are, and the key each row travels under. A changefeed and
2
+ // a snapshot speak TABLES; the page store keys records by the entity's NAME and PRIMARY KEY. Both
3
+ // come from the entity's own projection here, on the server — the browser never derives a key.
4
+
5
+ import type { Row } from '@ultimat3/core/page';
6
+ import { recordProjectionForTable } from '@ultimat3/entity/record';
7
+
8
+ export interface LiveRecords {
9
+ /** The entity name — or the table itself when no registered entity owns it. */
10
+ readonly type: string;
11
+ /** The row's record key, or `null` for a plain table, whose rows are keyed by `id`. */
12
+ readonly key: ((row: Row) => string) | null;
13
+ }
14
+
15
+ export function liveRecords(table: string): LiveRecords {
16
+ const projection = recordProjectionForTable(table);
17
+ if (projection === undefined) return { type: table, key: null };
18
+ return { type: projection.type, key: (row) => projection.key(row) };
19
+ }
package/src/live-rows.ts CHANGED
@@ -1,90 +1,101 @@
1
- // One live subscription's window, projected out of the identity map. The registration owns the
2
- // ORDER (its ids) and the map owns the VALUES — which is what makes post #7 one object however
3
- // many queries returned it, and what makes a write through any of them reach all of them.
1
+ // One live subscription's window over the page's record store. The registration owns the ORDER
2
+ // (its ids) and the store owns the VALUES — which is what makes post #7 one object however many
3
+ // queries returned it, and what makes a write through any of them reach all of them.
4
4
 
5
+ import type { Row } from '@ultimat3/core/page';
5
6
  import { orderAfterPatches } from './apply-patches';
6
7
  import type { LiveCursor } from './cursor';
7
- import { type IdentityMap, type RowKey, type RowScope, rowKey } from './identity-map';
8
- import type { JsonValue, Row, RowPatch } from './json';
8
+ import type { JsonValue, RowPatch } from './json';
9
+ import { type RecordKey, type RecordStore, recordKey } from './record-store';
9
10
 
10
- export type LiveState = 'loading' | 'live' | 'stale' | 'offline';
11
+ export type LiveState = 'loading' | 'live' | 'stale' | 'offline' | 'failed';
11
12
 
12
13
  /** One live query this client holds. Mutable: the ids and cursor a frame advances live here. */
13
14
  export interface Registration {
14
15
  readonly sid: string;
15
16
  readonly name: string;
16
17
  readonly input: JsonValue;
17
- readonly setRows: (rows: readonly Row[]) => void;
18
- readonly setState: (state: LiveState) => void;
19
- readonly setCursor: (cursor: LiveCursor | null) => void;
20
- /** Where this window's rows live in the map: the entity the server named, or a private scope. */
21
- scope: RowScope;
22
- /** Membership and order. The values are the map's — never a second copy of them. */
18
+ /** The record type the server named for this window; `unnamedType(name)` until it does. */
19
+ type: string;
20
+ /** Membership and order. The values are the store's — never a second copy of them. */
23
21
  ids: readonly string[];
24
22
  cursor: LiveCursor | null;
23
+ state: LiveState;
24
+ /** What the node answered when it refused this subscription. Set with `state: 'failed'`. */
25
+ error: unknown;
26
+ /** Called after anything a reader of this window renders has moved. */
27
+ readonly notify: () => void;
25
28
  }
26
29
 
27
30
  /**
28
- * Every open window over one identity map. It is the only writer of `Registration.ids`, so the
29
- * retain/release pairs that keep the map from growing without end cannot be forgotten by a caller.
31
+ * Where a window's rows live when the server named no record type: `?` starts no entity name, so
32
+ * two unnamed windows sharing an id never merge two entities' rows. Not a record type — only a
33
+ * snapshot from a node that cannot name the entity lands here.
34
+ */
35
+ export function unnamedType(queryName: string): string {
36
+ return `?query:${queryName}`;
37
+ }
38
+
39
+ /**
40
+ * Every open window over the one store. It is the only writer of `Registration.ids`, so the
41
+ * retain/release pairs that keep the store from growing without end cannot be forgotten.
30
42
  */
31
43
  export class RowWindows {
32
- readonly #identity: IdentityMap;
33
- /** The window a write is running for, so its own listener does not emit the same rows twice. */
44
+ readonly #store: RecordStore;
45
+ /** The window a write is running for, so its own listener does not notify it twice. */
34
46
  #writing: Registration | null = null;
35
47
 
36
- constructor(identity: IdentityMap) {
37
- this.#identity = identity;
48
+ constructor(store: RecordStore) {
49
+ this.#store = store;
38
50
  }
39
51
 
40
- /**
41
- * Start rendering this registration out of the map. The returned close releases its rows and
42
- * drops its listener — an unsubscribed component must stop holding rows and stop hearing about
43
- * them in the same call, or one of the two outlives the other.
44
- */
52
+ /** Render this registration out of the store; the returned close releases every row it held. */
45
53
  open(registration: Registration): () => void {
46
- const unsubscribe = this.#identity.subscribe((changed) => {
54
+ const unsubscribe = this.#store.subscribe((changed) => {
47
55
  if (this.#writing === registration) return;
48
- if (!holds(registration, changed)) return;
49
- this.#emit(registration);
56
+ if (holds(registration, changed)) registration.notify();
50
57
  });
51
58
  return () => {
52
59
  unsubscribe();
53
- this.#identity.batch(() => {
54
- for (const id of registration.ids) this.#identity.release(registration.scope, id);
60
+ this.#store.batch(() => {
61
+ for (const id of registration.ids) this.#store.release(registration.type, id);
55
62
  registration.ids = [];
56
63
  });
57
64
  };
58
65
  }
59
66
 
60
- /**
61
- * A snapshot: server truth for the whole window. `entity` is the scope the server named for this
62
- * subscription — the first one that arrives upgrades a private scope to the shared one, which is
63
- * what lets two different queries over one entity meet on the same row.
64
- */
65
- snapshot(registration: Registration, entity: string | null, rows: readonly Row[]): void {
66
- const scope = entity ?? registration.scope;
67
+ /** A snapshot: server truth for the whole window, under the record type the server named. */
68
+ snapshot(
69
+ registration: Registration,
70
+ type: string | null,
71
+ rows: readonly Row[],
72
+ keys?: readonly string[],
73
+ ): void {
74
+ const next = type ?? registration.type;
75
+ // The server's record key where it sent one; a row's `id` IS its key everywhere else.
76
+ const keyed = rows.map((row, index) => [keys?.[index] ?? String(row['id']), row] as const);
67
77
  this.#reseat(
68
78
  registration,
69
- scope,
70
- rows.map((row) => row.id),
71
- (held) => {
72
- for (const row of rows) {
73
- if (held.has(row.id)) this.#identity.merge(scope, row.id, row);
74
- }
79
+ next,
80
+ keyed.map(([id]) => id),
81
+ () => {
82
+ for (const [id, row] of keyed) this.#store.merge(next, id, row);
75
83
  },
76
84
  );
77
85
  }
78
86
 
79
- /** A patch list: values merged into the map, membership and order folded over the ids. */
80
- patch(registration: Registration, patches: readonly RowPatch[]): void {
81
- const scope = registration.scope;
82
- // A `delete` is this window losing the row, never the map losing it: another window holding
83
- // the same row keeps it until its own delete arrives.
84
- this.#reseat(registration, scope, orderAfterPatches(registration.ids, patches), (held) => {
87
+ /** A patch list: values merged into the store, membership and order folded over the ids. */
88
+ patch(registration: Registration, sent: readonly RowPatch[]): void {
89
+ const type = registration.type;
90
+ // A window holds RECORD keys: a patch the server keyed is folded under its key, not its id.
91
+ const patches = sent.map((patch) =>
92
+ patch.key === undefined ? patch : { ...patch, id: patch.key },
93
+ );
94
+ // A `delete` is this window losing the row, never the store losing it: another holder keeps it.
95
+ this.#reseat(registration, type, orderAfterPatches(registration.ids, patches), (held) => {
85
96
  for (const patch of patches) {
86
97
  if (patch.op === 'delete' || patch.row === null) continue;
87
- if (held.has(patch.id)) this.#identity.merge(scope, patch.id, patch.row);
98
+ if (held.has(patch.id)) this.#store.merge(type, patch.id, patch.row);
88
99
  }
89
100
  });
90
101
  }
@@ -93,51 +104,43 @@ export class RowWindows {
93
104
  rows(registration: Registration): readonly Row[] {
94
105
  const out: Row[] = [];
95
106
  for (const id of registration.ids) {
96
- const row = this.#identity.peek(registration.scope, id);
107
+ const row = this.#store.peek(registration.type, id);
97
108
  if (row !== undefined) out.push(row);
98
109
  }
99
110
  return out;
100
111
  }
101
112
 
102
113
  /**
103
- * Move the window to `nextIds` under `scope`, writing values in between. One batch per frame,
104
- * and one emit for the window that caused it.
105
- *
114
+ * Move the window to `nextIds` under `type`, writing values in between — one batch, one notify.
106
115
  * The retain comes before the write and the release after it, so a row this window keeps across
107
- * the move never reaches zero holds and gets dropped out from under the value it is about to be
108
- * given. `write` only touches ids the window ends up holding — a value nobody holds is a value
109
- * no release will ever reclaim.
116
+ * the move never reaches zero holds and is evicted out from under the value it is being given.
110
117
  */
111
118
  #reseat(
112
119
  registration: Registration,
113
- scope: RowScope,
120
+ type: string,
114
121
  nextIds: readonly string[],
115
122
  write: (held: ReadonlySet<string>) => void,
116
123
  ): void {
117
124
  const previous = this.#writing;
118
125
  this.#writing = registration;
119
126
  try {
120
- this.#identity.batch(() => {
121
- for (const id of nextIds) this.#identity.retain(scope, id);
127
+ this.#store.batch(() => {
128
+ for (const id of nextIds) this.#store.retain(type, id);
122
129
  write(new Set(nextIds));
123
- for (const id of registration.ids) this.#identity.release(registration.scope, id);
124
- registration.scope = scope;
130
+ for (const id of registration.ids) this.#store.release(registration.type, id);
131
+ registration.type = type;
125
132
  registration.ids = nextIds;
126
133
  });
127
134
  } finally {
128
135
  this.#writing = previous;
129
136
  }
130
- this.#emit(registration);
131
- }
132
-
133
- #emit(registration: Registration): void {
134
- registration.setRows(this.rows(registration));
137
+ registration.notify();
135
138
  }
136
139
  }
137
140
 
138
- function holds(registration: Registration, changed: ReadonlySet<RowKey>): boolean {
141
+ function holds(registration: Registration, changed: ReadonlySet<RecordKey>): boolean {
139
142
  for (const id of registration.ids) {
140
- if (changed.has(rowKey(registration.scope, id))) return true;
143
+ if (changed.has(recordKey(registration.type, id))) return true;
141
144
  }
142
145
  return false;
143
146
  }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * The page's durable client store: persisted records and the one outbox, in IndexedDB, every entry
3
+ * keyed by the principal it belongs to so one principal's cache can never restore into another's.
4
+ * IndexedDB blocked (a private window, storage disabled) falls back to memory with ONE warning,
5
+ * `X_LOCAL_STORE_UNAVAILABLE` — a page never breaks because it cannot remember.
6
+ */
7
+
8
+ import type { ClientScope, RecordRows, Row } from '@ultimat3/core/page';
9
+ import { isJsonObject, renderThrowable, type UltimateError } from '@ultimat3/core/page';
10
+ import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike } from './idb-types';
11
+ import type { QueueState } from './offline-queue';
12
+ import { LocalStoreUnavailableError } from './page-errors';
13
+
14
+ /** One persisted row, by record type and record key. */
15
+ export interface PersistedRow {
16
+ readonly type: string;
17
+ readonly key: string;
18
+ readonly row: Row;
19
+ }
20
+
21
+ export interface LocalStore {
22
+ readonly kind: 'indexeddb' | 'memory';
23
+ /** Every persisted row of one scope: record type -> record key -> row. */
24
+ rows(scope: string): Promise<ReadonlyMap<string, RecordRows>>;
25
+ write(
26
+ scope: string,
27
+ puts: readonly PersistedRow[],
28
+ deletes: readonly Omit<PersistedRow, 'row'>[],
29
+ ): Promise<void>;
30
+ queue(scope: string): Promise<QueueState | undefined>;
31
+ saveQueue(scope: string, state: QueueState): Promise<void>;
32
+ /** Everything of one scope — its rows AND its outbox. Sign-out, or any principal change. */
33
+ wipe(scope: string): Promise<void>;
34
+ /**
35
+ * Everything of every scope but `keep` — rows and outboxes. The page boot's answer to a sign-out
36
+ * by full navigation, which never calls `rescope()` and so never reaches `wipe`.
37
+ */
38
+ wipeOthers(keep: string): Promise<void>;
39
+ }
40
+
41
+ /**
42
+ * The storage key of a principal, or `undefined` for an UNSCOPED page (rendered for nobody):
43
+ * nothing is persisted there, because there is no principal to key it by. Prefixed so a principal
44
+ * spelled `anon` can never share the anonymous visitor's rows.
45
+ */
46
+ export function scopeKey(principal: ClientScope['principal']): string | undefined {
47
+ if (principal === undefined) return undefined;
48
+ return principal === null ? 'anon' : `p:${principal}`;
49
+ }
50
+
51
+ const RECORDS = 'records';
52
+ const OUTBOX = 'outbox';
53
+ /** JSON, never a joined string: a principal is opaque and may hold any separator. */
54
+ const rowKey = (scope: string, type: string, key: string): string =>
55
+ JSON.stringify([scope, type, key]);
56
+
57
+ /** Memory: tests, SSR, and the fallback when IndexedDB is unavailable. */
58
+ export class MemoryLocalStore implements LocalStore {
59
+ readonly kind = 'memory';
60
+ readonly #rows = new Map<string, PersistedRow & { readonly scope: string }>();
61
+ readonly #queues = new Map<string, QueueState>();
62
+
63
+ async rows(scope: string): Promise<ReadonlyMap<string, RecordRows>> {
64
+ return group([...this.#rows.values()].filter((entry) => entry.scope === scope));
65
+ }
66
+ async write(
67
+ scope: string,
68
+ puts: readonly PersistedRow[],
69
+ deletes: readonly Omit<PersistedRow, 'row'>[],
70
+ ): Promise<void> {
71
+ for (const { type, key } of deletes) this.#rows.delete(rowKey(scope, type, key));
72
+ for (const put of puts) this.#rows.set(rowKey(scope, put.type, put.key), { ...put, scope });
73
+ }
74
+ async queue(scope: string): Promise<QueueState | undefined> {
75
+ return this.#queues.get(scope);
76
+ }
77
+ async saveQueue(scope: string, state: QueueState): Promise<void> {
78
+ this.#queues.set(scope, structuredClone(state));
79
+ }
80
+ async wipe(scope: string): Promise<void> {
81
+ for (const [key, entry] of this.#rows) if (entry.scope === scope) this.#rows.delete(key);
82
+ this.#queues.delete(scope);
83
+ }
84
+ async wipeOthers(keep: string): Promise<void> {
85
+ for (const [key, entry] of this.#rows) if (entry.scope !== keep) this.#rows.delete(key);
86
+ for (const scope of [...this.#queues.keys()]) if (scope !== keep) this.#queues.delete(scope);
87
+ }
88
+ }
89
+
90
+ class IdbLocalStore implements LocalStore {
91
+ readonly kind = 'indexeddb';
92
+ constructor(private readonly db: IdbDatabaseLike) {}
93
+
94
+ async rows(scope: string): Promise<ReadonlyMap<string, RecordRows>> {
95
+ const tx = this.db.transaction(RECORDS, 'readonly');
96
+ const store = tx.objectStore(RECORDS);
97
+ const [keys, values] = await Promise.all([answer(store.getAllKeys()), answer(store.getAll())]);
98
+ const found: PersistedRow[] = [];
99
+ keys.forEach((raw, index) => {
100
+ const parts: unknown = typeof raw === 'string' ? JSON.parse(raw) : undefined;
101
+ const row = values[index];
102
+ if (!Array.isArray(parts) || parts[0] !== scope || !isJsonObject(row)) return;
103
+ found.push({ type: String(parts[1]), key: String(parts[2]), row });
104
+ });
105
+ return group(found);
106
+ }
107
+ async write(
108
+ scope: string,
109
+ puts: readonly PersistedRow[],
110
+ deletes: readonly Omit<PersistedRow, 'row'>[],
111
+ ): Promise<void> {
112
+ const tx = this.db.transaction(RECORDS, 'readwrite');
113
+ const store = tx.objectStore(RECORDS);
114
+ for (const { type, key } of deletes) store.delete(rowKey(scope, type, key));
115
+ for (const put of puts) store.put(put.row, rowKey(scope, put.type, put.key));
116
+ await done(tx);
117
+ }
118
+ async queue(scope: string): Promise<QueueState | undefined> {
119
+ const tx = this.db.transaction(OUTBOX, 'readonly');
120
+ const store = tx.objectStore(OUTBOX);
121
+ const [keys, values] = await Promise.all([answer(store.getAllKeys()), answer(store.getAll())]);
122
+ const at = keys.indexOf(scope);
123
+ return at === -1 ? undefined : (values[at] as QueueState);
124
+ }
125
+ async saveQueue(scope: string, state: QueueState): Promise<void> {
126
+ const tx = this.db.transaction(OUTBOX, 'readwrite');
127
+ tx.objectStore(OUTBOX).put(state, scope);
128
+ await done(tx);
129
+ }
130
+ async wipe(scope: string): Promise<void> {
131
+ const tx = this.db.transaction([RECORDS, OUTBOX], 'readwrite');
132
+ const records = tx.objectStore(RECORDS);
133
+ const keys = await answer(records.getAllKeys());
134
+ for (const raw of keys) {
135
+ if (typeof raw !== 'string') continue;
136
+ const parts: unknown = JSON.parse(raw);
137
+ if (Array.isArray(parts) && parts[0] === scope) records.delete(raw);
138
+ }
139
+ tx.objectStore(OUTBOX).delete(scope);
140
+ await done(tx);
141
+ }
142
+ async wipeOthers(keep: string): Promise<void> {
143
+ const tx = this.db.transaction([RECORDS, OUTBOX], 'readwrite');
144
+ const records = tx.objectStore(RECORDS);
145
+ const outbox = tx.objectStore(OUTBOX);
146
+ const [rowKeys, queueKeys] = await Promise.all([
147
+ answer(records.getAllKeys()),
148
+ answer(outbox.getAllKeys()),
149
+ ]);
150
+ for (const raw of rowKeys) {
151
+ if (typeof raw !== 'string') continue;
152
+ const parts: unknown = JSON.parse(raw);
153
+ if (Array.isArray(parts) && parts[0] !== keep) records.delete(raw);
154
+ }
155
+ for (const scope of queueKeys) {
156
+ if (typeof scope === 'string' && scope !== keep) outbox.delete(scope);
157
+ }
158
+ await done(tx);
159
+ }
160
+ }
161
+
162
+ export interface OpenLocalStoreOptions {
163
+ /** Default `globalThis.indexedDB`, read at call time. */
164
+ readonly indexedDB?: IdbFactoryLike | undefined;
165
+ /** Where the one fallback warning goes. Default `console.warn`. */
166
+ readonly warn?: ((error: UltimateError) => void) | undefined;
167
+ readonly name?: string | undefined;
168
+ }
169
+
170
+ /** IndexedDB when the page has it and it opens; memory, warned once, when it does not. */
171
+ export async function openLocalStore(options: OpenLocalStoreOptions = {}): Promise<LocalStore> {
172
+ // The browser's `IDBFactory` IS this shape at runtime; its DOM typings (event-typed handlers)
173
+ // are wider than the slice `idb-types.ts` declares, so the ambient value is narrowed once here.
174
+ const ambient: unknown = Reflect.get(globalThis, 'indexedDB');
175
+ const factory = options.indexedDB ?? (ambient as IdbFactoryLike | undefined);
176
+ const warn = options.warn ?? ((error: UltimateError) => console.warn(error));
177
+ if (factory === undefined) {
178
+ warn(new LocalStoreUnavailableError({ reason: 'this runtime has no indexedDB' }));
179
+ return new MemoryLocalStore();
180
+ }
181
+ try {
182
+ return new IdbLocalStore(await openDatabase(factory, options.name ?? 'ultimate-client'));
183
+ } catch (error) {
184
+ warn(
185
+ new LocalStoreUnavailableError({
186
+ reason: `indexedDB.open failed: ${renderThrowable(error)}`,
187
+ }),
188
+ );
189
+ return new MemoryLocalStore();
190
+ }
191
+ }
192
+
193
+ function openDatabase(factory: IdbFactoryLike, name: string): Promise<IdbDatabaseLike> {
194
+ return new Promise((resolve, reject) => {
195
+ const request = factory.open(name, 1);
196
+ request.onupgradeneeded = (): void => {
197
+ const db = request.result;
198
+ if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS);
199
+ if (!db.objectStoreNames.contains(OUTBOX)) db.createObjectStore(OUTBOX);
200
+ };
201
+ request.onsuccess = (): void => resolve(request.result);
202
+ request.onerror = (): void => reject(request.error);
203
+ request.onblocked = (): void => reject(request.error ?? 'blocked by another open tab');
204
+ });
205
+ }
206
+
207
+ function answer<T>(request: IdbRequestLike<T>): Promise<T> {
208
+ return new Promise((resolve, reject) => {
209
+ request.onsuccess = (): void => resolve(request.result);
210
+ request.onerror = (): void => reject(request.error);
211
+ });
212
+ }
213
+
214
+ function done(tx: {
215
+ oncomplete: (() => void) | null;
216
+ onerror: (() => void) | null;
217
+ error: unknown;
218
+ }): Promise<void> {
219
+ return new Promise((resolve, reject) => {
220
+ tx.oncomplete = (): void => resolve();
221
+ tx.onerror = (): void => reject(tx.error);
222
+ });
223
+ }
224
+
225
+ function group(entries: readonly PersistedRow[]): ReadonlyMap<string, RecordRows> {
226
+ const out = new Map<string, Record<string, Row>>();
227
+ for (const { type, key, row } of entries) {
228
+ const rows = out.get(type) ?? (Object.create(null) as Record<string, Row>);
229
+ Object.defineProperty(rows, key, { value: row, enumerable: true });
230
+ out.set(type, rows);
231
+ }
232
+ return out;
233
+ }
234
+
235
+ const PAGE_KEY: unique symbol = Symbol.for('ultimate.local-store');
236
+ type PageHost = { [PAGE_KEY]?: Promise<LocalStore> };
237
+
238
+ /**
239
+ * The page's ONE durable store, opened once per tab whichever island asks first — the persister and
240
+ * the outbox share it, so a wipe on a principal change reaches both. On `globalThis`, like the
241
+ * record store, because every island bundle carries its own copy of this module.
242
+ */
243
+ export function pageLocalStore(): Promise<LocalStore> {
244
+ const host = globalThis as PageHost;
245
+ const existing = host[PAGE_KEY];
246
+ if (existing !== undefined) return existing;
247
+ const opened = openLocalStore();
248
+ Object.defineProperty(host, PAGE_KEY, { value: opened, configurable: true });
249
+ return opened;
250
+ }
@@ -9,9 +9,9 @@
9
9
  // to a socket and nothing more, so a drained mutation is `inflight` — not `acked` — until an
10
10
  // `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
11
11
 
12
- import { renderThrowable, stringField } from '@ultimat3/core';
12
+ import { renderThrowable, stringField } from '@ultimat3/core/page';
13
13
  import type { JsonValue } from './json';
14
- import { type Frame, PROTOCOL_VERSION, type WireError } from './sync-protocol';
14
+ import type { WireError } from './sync-protocol';
15
15
 
16
16
  export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
17
17
 
@@ -33,7 +33,7 @@ export interface QueueState {
33
33
  readonly nextSeq: number;
34
34
  }
35
35
 
36
- /** Durability seam: OPFS/IndexedDB in the browser, memory in tests. */
36
+ /** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
37
37
  export interface QueueStore {
38
38
  load(): Promise<QueueState>;
39
39
  save(state: QueueState): Promise<void>;
@@ -131,8 +131,8 @@ export class OfflineQueue {
131
131
  // UI — collapsing onto it makes an explicit idempotency key unusable for the rest of the
132
132
  // session, because nothing ever retries a denial. Re-issuing one is a NEW intent, so the old
133
133
  // entry is dropped and this one takes a new sequence at the back of the queue.
134
- if (existing)
135
- this.#mutations = this.#mutations.filter((candidate) => candidate.key !== args.key);
134
+ // By identity: `existing` IS the entry for this key, found above — no second key comparison.
135
+ if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
136
136
  const mutation: QueuedMutation = {
137
137
  key: args.key,
138
138
  seq: this.#nextSeq,
@@ -204,7 +204,8 @@ export class OfflineQueue {
204
204
  const mutation = this.find(key);
205
205
  if (!mutation) return;
206
206
  mutation.status = 'acked';
207
- this.#mutations = this.#mutations.filter((candidate) => candidate.key !== key);
207
+ // By identity: `find` already matched it, and a second comparison of the key is the same question.
208
+ this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
208
209
  await this.#persist();
209
210
  }
210
211
 
@@ -316,18 +317,8 @@ export class OfflineQueue {
316
317
  }
317
318
  }
318
319
 
319
- export function mutateFrame(mutation: QueuedMutation): Frame {
320
- return {
321
- type: 'mutate',
322
- v: PROTOCOL_VERSION,
323
- key: mutation.key,
324
- seq: mutation.seq,
325
- name: mutation.name,
326
- input: mutation.input,
327
- };
328
- }
329
-
330
- function toQueueError(error: unknown): WireError {
320
+ /** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
321
+ export function toQueueError(error: unknown): WireError {
331
322
  return {
332
323
  // `stringField`, not `shape?.code`: the sender is a transport the app supplied, so the probe
333
324
  // for "did it throw a coded error" is itself a property read on an app value. A getter that