@catalyst-cloud/sdk 0.7.0 → 0.8.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 (74) hide show
  1. package/README.md +92 -0
  2. package/dist/browser.d.ts +9 -0
  3. package/dist/browser.d.ts.map +1 -0
  4. package/dist/browser.js +24 -0
  5. package/dist/browser.js.map +1 -0
  6. package/dist/live-sync-client.d.ts +128 -9
  7. package/dist/live-sync-client.d.ts.map +1 -1
  8. package/dist/live-sync-client.js +369 -51
  9. package/dist/live-sync-client.js.map +1 -1
  10. package/dist/replica/browser/apply.d.ts +29 -0
  11. package/dist/replica/browser/apply.d.ts.map +1 -0
  12. package/dist/replica/browser/apply.js +59 -0
  13. package/dist/replica/browser/apply.js.map +1 -0
  14. package/dist/replica/browser/browser-lock.d.ts +25 -0
  15. package/dist/replica/browser/browser-lock.d.ts.map +1 -0
  16. package/dist/replica/browser/browser-lock.js +91 -0
  17. package/dist/replica/browser/browser-lock.js.map +1 -0
  18. package/dist/replica/browser/browser-replica.d.ts +237 -0
  19. package/dist/replica/browser/browser-replica.d.ts.map +1 -0
  20. package/dist/replica/browser/browser-replica.js +952 -0
  21. package/dist/replica/browser/browser-replica.js.map +1 -0
  22. package/dist/replica/browser/db.worker.d.ts +2 -0
  23. package/dist/replica/browser/db.worker.d.ts.map +1 -0
  24. package/dist/replica/browser/db.worker.js +40 -0
  25. package/dist/replica/browser/db.worker.js.map +1 -0
  26. package/dist/replica/browser/delta-queue.d.ts +187 -0
  27. package/dist/replica/browser/delta-queue.d.ts.map +1 -0
  28. package/dist/replica/browser/delta-queue.js +328 -0
  29. package/dist/replica/browser/delta-queue.js.map +1 -0
  30. package/dist/replica/browser/ports.d.ts +39 -0
  31. package/dist/replica/browser/ports.d.ts.map +1 -0
  32. package/dist/replica/browser/ports.js +144 -0
  33. package/dist/replica/browser/ports.js.map +1 -0
  34. package/dist/replica/browser/protocol.d.ts +129 -0
  35. package/dist/replica/browser/protocol.d.ts.map +1 -0
  36. package/dist/replica/browser/protocol.js +12 -0
  37. package/dist/replica/browser/protocol.js.map +1 -0
  38. package/dist/replica/browser/seed-read-gate.d.ts +13 -0
  39. package/dist/replica/browser/seed-read-gate.d.ts.map +1 -0
  40. package/dist/replica/browser/seed-read-gate.js +36 -0
  41. package/dist/replica/browser/seed-read-gate.js.map +1 -0
  42. package/dist/replica/browser/seed-session.d.ts +23 -0
  43. package/dist/replica/browser/seed-session.d.ts.map +1 -0
  44. package/dist/replica/browser/seed-session.js +57 -0
  45. package/dist/replica/browser/seed-session.js.map +1 -0
  46. package/dist/replica/browser/snapshot-stream.d.ts +24 -0
  47. package/dist/replica/browser/snapshot-stream.d.ts.map +1 -0
  48. package/dist/replica/browser/snapshot-stream.js +113 -0
  49. package/dist/replica/browser/snapshot-stream.js.map +1 -0
  50. package/dist/replica/browser/sqlite-db.d.ts +9 -0
  51. package/dist/replica/browser/sqlite-db.d.ts.map +1 -0
  52. package/dist/replica/browser/sqlite-db.js +28 -0
  53. package/dist/replica/browser/sqlite-db.js.map +1 -0
  54. package/dist/replica/browser/support.d.ts +2 -0
  55. package/dist/replica/browser/support.d.ts.map +1 -0
  56. package/dist/replica/browser/support.js +30 -0
  57. package/dist/replica/browser/support.js.map +1 -0
  58. package/dist/replica/browser/validate.d.ts +17 -0
  59. package/dist/replica/browser/validate.d.ts.map +1 -0
  60. package/dist/replica/browser/validate.js +47 -0
  61. package/dist/replica/browser/validate.js.map +1 -0
  62. package/dist/replica/browser/worker-core.d.ts +10 -0
  63. package/dist/replica/browser/worker-core.d.ts.map +1 -0
  64. package/dist/replica/browser/worker-core.js +181 -0
  65. package/dist/replica/browser/worker-core.js.map +1 -0
  66. package/dist/replica/catalyst-replica.d.ts +6 -9
  67. package/dist/replica/catalyst-replica.d.ts.map +1 -1
  68. package/dist/replica/catalyst-replica.js +6 -15
  69. package/dist/replica/catalyst-replica.js.map +1 -1
  70. package/dist/replica/migration-shape.d.ts +11 -0
  71. package/dist/replica/migration-shape.d.ts.map +1 -0
  72. package/dist/replica/migration-shape.js +25 -0
  73. package/dist/replica/migration-shape.js.map +1 -0
  74. package/package.json +16 -3
@@ -0,0 +1,952 @@
1
+ // replica/browser/browser-replica.ts — the MAIN-THREAD client for the OPFS replica (CTC-51 → CTC-114,
2
+ // ADR-0002). Owns the Worker (db.worker.ts), drives it over the protocol.ts RPC, and wires the cloud
3
+ // transport:
4
+ //
5
+ // • LOCK — claim single-ownership of the origin's replica via Web Locks (browser-lock.ts) BEFORE
6
+ // loading any worker or wasm. A losing tab surfaces "secondary" — a clean state, not an
7
+ // error — and stays on its fetch+live path (CTC-118).
8
+ // • SEED — STREAM the /snapshot NDJSON (same endpoint the node CatalystReplica seeds from): decode
9
+ // it incrementally into bounded batches (snapshot-stream.ts) and drive the worker over
10
+ // seedBegin→seedBatch*→seedCommit so no layer buffers the whole (~100 MB) body (CTC-132 —
11
+ // peak memory is one batch).
12
+ // • LIVE — the SAME `LiveSyncClient` that feeds the node replica feeds this in-browser OPFS DB,
13
+ // through a coalescing, single-flight, depth-bounded DeltaQueue (CTC-318) — never one RPC
14
+ // per frame.
15
+ //
16
+ // AUTH: the browser rides the httpOnly session cookie on same-origin requests — there is NO Bearer
17
+ // token here (that's the node path). `auth:{kind:"cookie"}` appends NOTHING to the connect URL (the
18
+ // type system + the SDK's single URL-construction point make a browser token leak impossible).
19
+ //
20
+ // PACKAGING (CTC-114): the worker is constructed with `new Worker(new URL("./db.worker.js",
21
+ // import.meta.url), { type: "module" })` — the idiom Vite, webpack 5 and Rollup statically detect and
22
+ // split into its own chunk (the wasm + worker code never touch the consumer's main bundle). Consumers
23
+ // with an exotic bundler can inject `createWorker` instead.
24
+ import { LiveSyncClient } from "../../live-sync-client.js";
25
+ import { streamSnapshotBatches } from "./snapshot-stream.js";
26
+ import { DeltaQueue } from "./delta-queue.js";
27
+ import { acquireReplicaLock } from "./browser-lock.js";
28
+ import { requireNonNegativeFinite } from "./validate.js";
29
+ /** Where the replica DB file + its OPFS dir live by default. Absolute path (SAHPool requires a leading
30
+ * slash); one directory per origin-app so engines don't collide. */
31
+ const DEFAULT_DB_PATH = "/catalyst-replica.sqlite3";
32
+ const DEFAULT_OPFS_DIR = ".catalyst-replica";
33
+ /** How long a cooperative `close` RPC gets to release the OPFS handles before the Worker is
34
+ * terminated anyway — teardown must be bounded (a wedged worker can't be allowed to leak forever). */
35
+ const CLOSE_GRACE_MS = 250;
36
+ /**
37
+ * How long the seed tolerates NO PROGRESS before aborting. IDLE, never total duration: a legitimate
38
+ * ~100 MB snapshot streams for minutes, and the node replica rejects a total cap for exactly that
39
+ * reason. The timer re-arms before the fetch, on response headers, and on every body chunk — so this
40
+ * only fires when the server has genuinely stopped sending.
41
+ */
42
+ const DEFAULT_SNAPSHOT_IDLE_TIMEOUT_MS = 30_000;
43
+ /**
44
+ * How long a SEED's worker RPC may go unanswered before the worker is declared dead (CTC-114 review
45
+ * round 6).
46
+ *
47
+ * Separate from the snapshot idle bound above, because they measure different things and one number
48
+ * cannot serve both. That bound watches the NETWORK and is deliberately down while the worker works —
49
+ * which left a worker that accepts a `postMessage` and never replies unbounded, since `call()` has no
50
+ * deadline of its own. That is not merely a hang: the worker is sitting in an OPEN seed transaction
51
+ * with its `SeedReadGate` armed, so every later read waits forever and delta applies fail as nested
52
+ * transactions. On a live reseed it is worse — `LiveSyncClient.boundedReseed` abandons the callback
53
+ * after its own deadline while that transaction and gate stay open, so the client carries on against a
54
+ * replica that can no longer answer anything.
55
+ *
56
+ * GENEROUS BY DESIGN: this exists to convert a WEDGED worker into a diagnosable error, not to police
57
+ * slow-but-progressing storage. A `seedCommit` of a ~100 MB snapshot on throttled OPFS is legitimately
58
+ * slow, and aborting one that is about to succeed is the exact failure the round-5 fix removed. Four
59
+ * times the network idle bound, and overridable.
60
+ */
61
+ const DEFAULT_WORKER_RPC_TIMEOUT_MS = 120_000;
62
+ /**
63
+ * Request types whose deadline PAUSES while a seed is in flight — the reads.
64
+ *
65
+ * They are the one family whose pending time is not all the worker's own work: the `SeedReadGate`
66
+ * defers a read for as long as an open seed lasts (CTC-132), and a legitimate ~100 MB snapshot
67
+ * streams for minutes, so a flat deadline would abort healthy reads during exactly the operation the
68
+ * gate exists to protect them from.
69
+ *
70
+ * Round 9 EXEMPTED them outright, on the reasoning that some other bounded RPC would notice a dead
71
+ * worker first. Round 10 showed that to be unfounded: on a quiet feed no apply and no seed need ever
72
+ * follow, so a UI awaiting `queryIssues()` hung forever. The exemption is gone — reads are bounded
73
+ * like everything else, but their window only counts time in which the worker could actually have
74
+ * answered. This is the same principle the snapshot idle bound already follows: measure the time the
75
+ * operation is able to progress, not wall-clock. We can do it precisely because THIS client drives
76
+ * the seed, so it knows exactly when a read is legitimately gated.
77
+ */
78
+ const SEED_GATED_WORKER_RPCS = new Set([
79
+ "queryIssues",
80
+ "queryIssueDetail",
81
+ "queryPulls",
82
+ ]);
83
+ /**
84
+ * Resolve the API base to a URL — the SINGLE point of construction.
85
+ *
86
+ * `snapshotUrl` and `subscribe` each derived this independently, which is a real hazard once a
87
+ * same-origin guard exists: the guard can pass on one derivation while the fetch is built from the
88
+ * other. One helper, used by both, makes that impossible.
89
+ */
90
+ function resolveBase(baseUrl) {
91
+ const isAbsolute = /^https?:\/\//i.test(baseUrl);
92
+ const origin = typeof location !== "undefined" ? location.origin : "http://localhost";
93
+ return isAbsolute ? new URL(baseUrl) : new URL(baseUrl, origin);
94
+ }
95
+ /**
96
+ * Reject a `baseUrl` that resolves off-origin.
97
+ *
98
+ * Cross-origin cannot work from the client side at all, so accepting it only produces an
99
+ * undiagnosable failure much later: `/snapshot` is served under the mirror's credential-free CORS
100
+ * posture (no `Access-Control-Allow-Credentials`), and the session cookie is host-only, so a
101
+ * cross-origin seed is unauthenticated no matter what this client sends. Failing here, before any
102
+ * resource is claimed, turns that into one actionable message. An absolute-but-same-origin base stays
103
+ * legal — it is the documented form. Returns early when `location` is undefined (SSR / node tests).
104
+ */
105
+ function assertSameOriginBase(baseUrl) {
106
+ if (typeof location === "undefined")
107
+ return;
108
+ const resolved = resolveBase(baseUrl);
109
+ if (resolved.origin !== location.origin) {
110
+ throw new Error(`BrowserReplica: baseUrl must resolve to this origin (${location.origin}); got ${resolved.origin}. ` +
111
+ "Cross-origin replicas cannot authenticate — /snapshot is served without Access-Control-Allow-Credentials and the session cookie is host-only.");
112
+ }
113
+ }
114
+ /**
115
+ * Turn a worker `error` / `messageerror` event into an actionable Error.
116
+ *
117
+ * A module-script load failure fires a BARE `Event` with no `.message` and no `.error` — so the
118
+ * natural `event.message` read yields `undefined` and the consumer gets nothing at all. This is the
119
+ * single most likely first-integration failure for a third party (the bundler didn't emit the worker
120
+ * chunk, CSP blocks `worker-src`, or the sqlite-wasm peer is missing), so it is worth spelling out.
121
+ */
122
+ function describeWorkerError(event) {
123
+ const withError = event;
124
+ if (withError.error instanceof Error)
125
+ return withError.error;
126
+ if (typeof withError.message === "string" && withError.message.length > 0) {
127
+ return new Error(`replica worker failed: ${withError.message}`);
128
+ }
129
+ return new Error("replica worker failed to load. The bundler may not have emitted the db.worker chunk — " +
130
+ "pass createWorker with the '@catalyst-cloud/sdk/browser/db-worker' specifier — or a Content-Security-Policy " +
131
+ "worker-src directive is blocking it, or the '@sqlite.org/sqlite-wasm' peer dependency is not installed.");
132
+ }
133
+ /** Resolve `${base}/snapshot` preserving an absolute base's path (mirror of the reads' apiUrl). */
134
+ function snapshotUrl(baseUrl, account) {
135
+ const base = resolveBase(baseUrl);
136
+ const basePath = base.pathname.replace(/\/$/, "");
137
+ const url = new URL(base.toString());
138
+ url.pathname = `${basePath}/snapshot`;
139
+ // The OPEN read plane scopes to the session's own tenant when ?account= is omitted; only pass it for
140
+ // an explicit tenant switcher.
141
+ if (account)
142
+ url.searchParams.set("account", account);
143
+ return url.toString();
144
+ }
145
+ /**
146
+ * Drive a streamed /snapshot body into the worker over the batched seed protocol: post `seedBegin`,
147
+ * decode the body into bounded batches (streamSnapshotBatches), post each as a `seedBatch`, capture the
148
+ * terminal cursor, then post `seedCommit`. On any error post `seedAbort` (best-effort) and rethrow so
149
+ * the whole seed rolls back. Exported (and pure over an injected `call`) so it is unit-testable without
150
+ * the wasm/OPFS worker (CTC-132).
151
+ */
152
+ export async function streamSeedIntoWorker(call, body, batchSize,
153
+ /** Fired whenever the body delivers bytes — the seed's idle-timeout re-arm. Optional, so every
154
+ * existing caller and test compiles unchanged. */
155
+ onProgress) {
156
+ try {
157
+ // seedBegin is INSIDE the try so a mid-begin failure (e.g. truncate threw with the txn already
158
+ // open) still triggers the rollback below (CTC-132 review finding B).
159
+ await call({ type: "seedBegin" });
160
+ let cursor = 0;
161
+ for await (const item of streamSnapshotBatches(body, batchSize, onProgress)) {
162
+ if (item.kind === "batch")
163
+ await call({ type: "seedBatch", rows: item.rows });
164
+ else
165
+ cursor = item.cursor;
166
+ }
167
+ await call({ type: "seedCommit", cursor });
168
+ return cursor;
169
+ }
170
+ catch (err) {
171
+ try {
172
+ await call({ type: "seedAbort" });
173
+ }
174
+ catch {
175
+ // worker already torn down / txn already aborted — surface the original error.
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+ /**
181
+ * BrowserReplica — claim the origin lock, open the Worker, seed from /snapshot, then keep the OPFS
182
+ * replica live off the SDK's `LiveSyncClient` (the SAME client that feeds the node replica). The
183
+ * consumer reads views via queryIssues/queryIssueDetail/queryPulls (each a Worker round-trip that runs
184
+ * the shared read-model builder over the local DB). `close()` tears down the SDK client + the Worker.
185
+ */
186
+ export class BrowserReplica {
187
+ worker = null;
188
+ options;
189
+ live = null;
190
+ lock = null;
191
+ /** The DURABLE cursor — advanced only once the worker has committed, and what a cold start reads. */
192
+ lastSeq = 0;
193
+ /**
194
+ * The TRANSPORT high-water: the highest seq ACCEPTED off the socket, advanced synchronously on
195
+ * arrival (CTC-318).
196
+ *
197
+ * The SDK re-baselines from `getCursor()` on EVERY socket open (`deliveredSeq = getCursor()`, then
198
+ * `{type:"sync", after}`), so reporting the durable cursor — which lags by the whole undrained
199
+ * buffer — made a reconnect mid-drain re-request everything already received-but-not-committed, and
200
+ * the DO replayed it into the same buffer on top of what was there. K reconnects meant K copies.
201
+ *
202
+ * In-memory only, deliberately: a page reload reads the durable cursor from `sync_meta`, so a tab
203
+ * that dies between accept and commit still re-requests those frames on its next cold start.
204
+ */
205
+ acceptedSeq = 0;
206
+ nextId = 1;
207
+ pending = new Map();
208
+ disposed = false;
209
+ /** Has start() been called? One-shot, like the node CatalystReplica — see start(). */
210
+ started = false;
211
+ /** Latched worker load/runtime failure, so every later call reports the CAUSE, not "closed". */
212
+ workerError = null;
213
+ /** Aborts the in-flight seed (close, supersede, or idle timeout). */
214
+ seedAbort = null;
215
+ /** Monotonic seed id — a superseded seed must not publish its cursor over a newer one's. */
216
+ seedGeneration = 0;
217
+ handlers;
218
+ /** Coalescing buffer between the socket and the worker (CTC-318) — see delta-queue.ts. */
219
+ deltas;
220
+ constructor(handlers, options) {
221
+ // The fence is only a fence if it holds a value. An untyped-JS consumer following the old README
222
+ // produced the shared key "undefined\0" for EVERY user, which is worse than no fence at all: it
223
+ // looks isolated and is not.
224
+ if (!options.identity) {
225
+ throw new Error("BrowserReplica: `identity` is required — pass a stable per-user id (e.g. the session user id). " +
226
+ "The OPFS database is shared across the origin and a warm cursor skips the snapshot, so without it a change of signed-in user serves the previous user's rows.");
227
+ }
228
+ // ISOLATE the consumer's callbacks ONCE, here, rather than at each of the ~16 call sites (CTC-114
229
+ // review round 4). These are arbitrary app code — a React setState that throws lands in whatever
230
+ // internal path happened to be notifying. The concrete finding: `onStatus("reconnecting")` is
231
+ // raised at the TOP of the overflow handler, so a throw there escaped before `acceptedSeq` was
232
+ // rolled back and `requestResync()` dispatched. `DeltaQueue.notify()` catches it, but the queue
233
+ // stays overflow-LATCHED — `push()` then refuses every subsequent frame while the socket happily
234
+ // stays up, i.e. a silently frozen replica still reporting itself connected.
235
+ //
236
+ // Wrapping at the boundary (not per-site) is deliberate: it cannot be forgotten by a later call
237
+ // site, and it holds for `onChanged` too. This is the same discipline DeltaQueue and the transport
238
+ // already apply to their own user handlers.
239
+ this.handlers = {
240
+ onStatus: (status) => {
241
+ try {
242
+ handlers.onStatus(status);
243
+ }
244
+ catch (err) {
245
+ console.error(`[replica] onStatus("${status}") handler threw`, err);
246
+ }
247
+ },
248
+ onChanged: () => {
249
+ try {
250
+ handlers.onChanged();
251
+ }
252
+ catch (err) {
253
+ console.error("[replica] onChanged handler threw", err);
254
+ }
255
+ },
256
+ };
257
+ // Validate the numeric knobs ONCE, here (CTC-114 review round 13) — the FOURTH appearance of this
258
+ // class, and the first inside an option I added myself. `NaN` defeats each of these the same way:
259
+ // `ms <= 0` is false, so the guard that disables the bound does not fire, and `setTimeout(fn, NaN)`
260
+ // is then coerced to a ZERO-delay timer. A NaN `workerRpcTimeoutMs` therefore declares a healthy
261
+ // worker wedged before its own `open` reply can arrive, terminates it, and rejects `start()` —
262
+ // the opposite of the "generous by design" contract documented on the constant.
263
+ //
264
+ // Round 12 introduced validate.ts for exactly this and I applied it to the exported helpers while
265
+ // walking past the options object of the main public class. All three of these accept 0 to disable
266
+ // the bound, so non-negative rather than positive.
267
+ for (const [label, value] of [
268
+ ["snapshotIdleTimeoutMs", options.snapshotIdleTimeoutMs],
269
+ ["workerRpcTimeoutMs", options.workerRpcTimeoutMs],
270
+ ["reseedTimeoutMs", options.reseedTimeoutMs],
271
+ ]) {
272
+ if (value !== undefined) {
273
+ requireNonNegativeFinite("BrowserReplica", label, value);
274
+ }
275
+ }
276
+ this.options = options;
277
+ this.deltas = new DeltaQueue({
278
+ // Bounded by `call()` itself (round 9) — a wedged apply used to leave the queue permanently
279
+ // `draining`, reaching NEITHER its retry nor its overflow recovery, while arriving frames kept
280
+ // advancing `acceptedSeq` so a reconnect resumed above changes that were never committed.
281
+ apply: (changes) => this.call({ type: "applyChanges", changes }),
282
+ onDrained: (cursor) => {
283
+ this.lastSeq = Math.max(this.lastSeq, cursor);
284
+ if (this.disposed)
285
+ return;
286
+ this.handlers.onStatus("live");
287
+ this.handlers.onChanged();
288
+ },
289
+ onError: (err) => {
290
+ if (this.disposed)
291
+ return;
292
+ console.error("[replica] applying live deltas failed:", err);
293
+ this.handlers.onStatus("error");
294
+ },
295
+ // THE COMPENSATION FOR A DISCARD, paired with it at the queue's seam (CTC-114 review round 12).
296
+ //
297
+ // Whatever the queue just dropped was accepted off the socket and is now in neither the store
298
+ // nor the buffer. `acceptedSeq` still describes it and `getCursor()` reports the max of the two,
299
+ // so without this the next `{type:"sync", after}` resumes ABOVE those frames, the gap detector
300
+ // re-baselines from the same poisoned value and sees contiguity, and the next applied batch
301
+ // carries the durable cursor past the hole — permanent, re-seed-only recovery.
302
+ //
303
+ // This lived in `onOverflow` and therefore covered ONE of the queue's six discard sites. Round
304
+ // 9's `pause()` was the second, and it lost data on every failed re-seed that began with frames
305
+ // still buffered. Rolling back to the DURABLE cursor is deliberately conservative: an apply
306
+ // still in flight may re-deliver rows the worker already has, and worker-core's
307
+ // `rec.seq <= maxSeq` stale-guard drops those. Re-requesting a few frames is free; missing one
308
+ // is not.
309
+ onDiscard: () => {
310
+ this.acceptedSeq = this.lastSeq;
311
+ },
312
+ // The queue has given up on what it holds and needs the owner to replace the DB wholesale —
313
+ // either the backlog outgrew replaying it ("depth") or applies kept rejecting ("apply-failed").
314
+ onOverflow: (depth, reason) => {
315
+ if (reason === "apply-failed") {
316
+ console.error(`[replica] ${depth} buffered frames could not be applied — re-seeding from /snapshot`);
317
+ }
318
+ else {
319
+ console.warn(`[replica] live backlog reached ${depth} frames — re-seeding from /snapshot instead of replaying`);
320
+ }
321
+ if (this.disposed)
322
+ return;
323
+ this.handlers.onStatus("reconnecting");
324
+ // Roll the TRANSPORT high-water back to the durable cursor. The queue just discarded its
325
+ // inbox, so `acceptedSeq` now describes frames that were received and then thrown away. If
326
+ // the replacement seed FAILS, the next connect would resume above those frames and seal a
327
+ // second hole that nothing ever re-requests.
328
+ this.acceptedSeq = this.lastSeq;
329
+ // Route through the TRANSPORT, not straight to reseed(). requestResync() closes the socket
330
+ // FIRST, which is the whole point: a bare reseed() runs while the socket is still delivering,
331
+ // and every frame written during the multi-second /snapshot lands in a window that is in
332
+ // neither the snapshot nor the DB — while the transport counts them as delivered, so the gap
333
+ // detector is structurally blind to them.
334
+ //
335
+ // Resuming ON SETTLE is the fix for the queue staying latched forever. `overflowed` gates
336
+ // every subsequent push, so when resume() ran only after a SUCCESSFUL re-seed, a transient
337
+ // snapshot error discarded every later frame while the socket stayed up — a silently stale
338
+ // replica reporting "live" until the tab was reloaded. requestResync() itself re-enters the
339
+ // backoff/reconnect path on a failed reseed, so the retry is the transport's.
340
+ //
341
+ // `.finally` rather than `.then` is belt-and-braces, NOT the mechanism: requestResync() never
342
+ // rejects by contract, so the two are equivalent today (verified — swapping them changes no
343
+ // test). It is written this way so the unlatch survives that contract changing.
344
+ const live = this.live;
345
+ if (live) {
346
+ void live.requestResync().finally(() => {
347
+ // Only unlatch if NO seed is in flight (round 9). requestResync() can return without
348
+ // re-seeding at all — the transport may already be resyncing, or the boot may have failed
349
+ // — and in the already-resyncing case a seed is running right now with the queue paused by
350
+ // its own preamble. Resuming here would clear that pause and let stale frames apply into
351
+ // its open transaction. When a seed IS running, its `finally` owns the unlatch; this arm
352
+ // exists for the case where requestResync did nothing and nobody else will.
353
+ if (!this.seedAbort)
354
+ this.deltas.resume();
355
+ });
356
+ return;
357
+ }
358
+ // No transport yet (overflow during boot, before subscribe()) — there is no socket to
359
+ // quiesce, so seed directly, but keep the same unlatch-on-either-arm discipline.
360
+ void this.reseed()
361
+ .catch((err) => {
362
+ console.error("[replica] re-seed after backlog overflow failed:", err);
363
+ if (!this.disposed)
364
+ this.handlers.onStatus("error");
365
+ })
366
+ .finally(() => this.deltas.resume());
367
+ },
368
+ });
369
+ }
370
+ /**
371
+ * The tenant fence sent with `open`.
372
+ *
373
+ * `accountId` is folded in alongside `identity` so a TENANT SWITCHER cannot reuse another account's
374
+ * rows even when the consumer's `identity` only identifies the signed-in user. NUL-joined because it
375
+ * cannot occur in either component, so no pair of values can collide by concatenation.
376
+ */
377
+ identityKey() {
378
+ return `${this.options.identity}\0${this.options.accountId ?? ""}`;
379
+ }
380
+ /**
381
+ * Send a typed request to the Worker and resolve with its result (cast by the ResultMap entry).
382
+ *
383
+ * BOUNDED HERE, for every request type, rather than at individual call sites (CTC-114 review round
384
+ * 9). Rounds 6 and 7 wrapped the seed RPCs and then the apply RPC one at a time, and round 9 found
385
+ * the ones still missed — `open` and `getCursor`, where a wedged `installOpfsSAHPoolVfs()` left
386
+ * `start()` pending forever while holding the origin-wide Web Lock, with every sibling tab reporting
387
+ * "secondary" and no worker `error` event to notice. Bounding at the seam instead makes that class
388
+ * of miss structural: a new request type is bounded by construction, not by remembering.
389
+ *
390
+ * EVERY type is bounded, reads included. Their window merely pauses while a seed is in flight —
391
+ * see {@link SEED_GATED_WORKER_RPCS}.
392
+ */
393
+ call(request) {
394
+ // A latched worker failure comes FIRST — above the disposed check — so the caller gets the real
395
+ // cause ("the bundler didn't emit the worker chunk") instead of the generic "replica client
396
+ // closed" that failWorker's teardown would otherwise produce.
397
+ if (this.workerError)
398
+ return Promise.reject(this.workerError);
399
+ // Once closed (or before start), there is no Worker to reply — reject synchronously rather than
400
+ // register a pending entry that would hang forever (CTC-132 review finding). This closes the
401
+ // teardown race: close() rejects an in-flight seedBatch, whose catch fires a fresh seedAbort call;
402
+ // without this guard that abort would postMessage a dead Worker and leave streamSeedIntoWorker /
403
+ // reseed() / start() pending indefinitely, retaining the client and a stranded pending-map entry.
404
+ if (this.disposed || !this.worker) {
405
+ return Promise.reject(new Error("replica client closed"));
406
+ }
407
+ const worker = this.worker;
408
+ const id = this.nextId++;
409
+ const envelope = { id, request };
410
+ const sent = new Promise((resolve, reject) => {
411
+ this.pending.set(id, {
412
+ resolve: (v) => resolve(v),
413
+ reject,
414
+ });
415
+ worker.postMessage(envelope);
416
+ });
417
+ return this.withWorkerDeadline(request.type, sent);
418
+ }
419
+ /**
420
+ * Boot the replica: claim the origin lock, open the OPFS DB (applyMigrations runs inside the
421
+ * worker), seed from /snapshot, then subscribe to the live SDK feed. Resolves once the seed lands
422
+ * (the first view query is valid after) — or immediately with status "secondary" when another tab
423
+ * owns the replica.
424
+ */
425
+ async start() {
426
+ // BEFORE any status change and before the lock: a tab pointed at the wrong origin must never
427
+ // claim the origin's Web Lock. If it did, it would win the election, fail its own seed, and hold
428
+ // every OTHER tab on the origin in "secondary" — a misconfiguration in one place taking out the
429
+ // replica everywhere.
430
+ assertSameOriginBase(this.options.baseUrl);
431
+ // One-shot, matching the node CatalystReplica. A second start() would otherwise construct a second
432
+ // worker while the first still holds the OPFS SyncAccessHandles, or (with the lock enabled) report
433
+ // a spurious "secondary" against this instance's own lock. Consumers construct a fresh instance
434
+ // per effect run, which is also what makes the failed-boot retry path work.
435
+ if (this.disposed) {
436
+ throw new Error("BrowserReplica: start() after close()");
437
+ }
438
+ if (this.started) {
439
+ throw new Error("BrowserReplica: start() already called");
440
+ }
441
+ this.started = true;
442
+ this.handlers.onStatus("loading");
443
+ // The lock comes FIRST — before the worker, before the wasm chunk — so a losing tab pays nothing.
444
+ if (!this.options.disableLock) {
445
+ let lock;
446
+ try {
447
+ lock = await acquireReplicaLock(`catalyst-replica:${this.options.directory ?? DEFAULT_OPFS_DIR}`);
448
+ }
449
+ catch (err) {
450
+ // A REJECTING lock manager is a BOOT FAILURE, not contention (CTC-114 review round 5). It used
451
+ // to be folded into the same `null` that means "another tab owns the replica", so start()
452
+ // resolved with the clean, terminal "secondary" state and the consumer sat on the fallback
453
+ // path forever with nothing to diagnose. Route it to the documented boot-error path instead.
454
+ // Nothing to release: the lock is claimed BEFORE the worker, so none exists yet.
455
+ console.error("[replica] boot failed: the Web Locks API rejected:", err);
456
+ if (!this.disposed)
457
+ this.handlers.onStatus("error");
458
+ throw err;
459
+ }
460
+ if (this.disposed) {
461
+ lock?.release();
462
+ return;
463
+ }
464
+ if (lock === null) {
465
+ this.handlers.onStatus("secondary");
466
+ return;
467
+ }
468
+ this.lock = lock;
469
+ }
470
+ try {
471
+ this.worker = this.options.createWorker
472
+ ? this.options.createWorker()
473
+ : new Worker(new URL("./db.worker.js", import.meta.url), {
474
+ type: "module",
475
+ });
476
+ // Register the FAILURE listeners immediately after construction and BEFORE the first
477
+ // postMessage. A module-script load failure is asynchronous and can land before `open` is even
478
+ // answered; without a listener there is nothing to reject against, so `start()` and every read
479
+ // behind it hang forever with no diagnostic anywhere.
480
+ this.worker.addEventListener("error", (e) => {
481
+ this.failWorker(describeWorkerError(e));
482
+ });
483
+ this.worker.addEventListener("messageerror", (e) => {
484
+ this.failWorker(describeWorkerError(e));
485
+ });
486
+ this.worker.addEventListener("message", (e) => {
487
+ const reply = e.data;
488
+ const slot = this.pending.get(reply.id);
489
+ if (!slot)
490
+ return;
491
+ this.pending.delete(reply.id);
492
+ if (reply.ok)
493
+ slot.resolve(reply.result);
494
+ else
495
+ slot.reject(new Error(reply.error));
496
+ });
497
+ await this.call({
498
+ type: "open",
499
+ dbPath: this.options.dbPath ?? DEFAULT_DB_PATH,
500
+ directory: this.options.directory ?? DEFAULT_OPFS_DIR,
501
+ identity: this.identityKey(),
502
+ });
503
+ // Prime the cursors from the PERSISTED OPFS cursor (a prior session survives reloads — SAHPool
504
+ // clearOnInit:false). The SDK reads this via getCursor on its first connect so a warm replica
505
+ // resumes the live feed from where it left off instead of re-seeding.
506
+ const persisted = await this.call({ type: "getCursor" });
507
+ if (persisted != null) {
508
+ this.lastSeq = persisted;
509
+ this.acceptedSeq = persisted;
510
+ }
511
+ // Seed only if we have never completed a snapshot (cold OPFS); a warm replica skips straight to
512
+ // live and lets {type:"sync", after:<persisted>} catch it up.
513
+ if (persisted == null) {
514
+ await this.reseed();
515
+ }
516
+ else if (!this.disposed) {
517
+ this.handlers.onStatus("live");
518
+ this.handlers.onChanged();
519
+ }
520
+ this.subscribe();
521
+ }
522
+ catch (err) {
523
+ // Surface the REAL cause (the worker preserves err.message → reply.error → here). Without this
524
+ // the failure was swallowed and the UI showed a bare "Replica error" with no diagnosable reason.
525
+ console.error("[replica] boot failed:", err);
526
+ // Release the worker AND the Web Lock. A failed boot used to leak both, which is far worse than
527
+ // it sounds: the lock is origin-wide, so one bad boot wedged EVERY other tab into "secondary"
528
+ // for the life of the document, and the worker kept the OPFS SyncAccessHandles.
529
+ //
530
+ // releaseResources(), NOT close() — close() sets `disposed`, which would silence the very next
531
+ // onStatus("error") line. It is deliberately non-terminal: its job is freeing the lock and the
532
+ // worker for sibling tabs and for the NEXT instance (this one stays one-shot).
533
+ this.releaseResources();
534
+ if (!this.disposed)
535
+ this.handlers.onStatus("error");
536
+ throw err;
537
+ }
538
+ }
539
+ /**
540
+ * Pull a fresh snapshot, replace the replica (seed), and return the snapshot cursor. Used on first
541
+ * start (cold OPFS) and on every SDK resync (cursor underflow).
542
+ *
543
+ * Bounded and supersedable. A stalled `/snapshot` must not leave `start()` unsettled with every read
544
+ * queued behind the worker's armed SeedReadGate, and a superseded seed must never publish its cursor
545
+ * or post another worker message — a late `seedCommit` from an abandoned seed can otherwise persist
546
+ * a stale cursor over a truncated DB, which the next cold start reads as WARM and goes live over.
547
+ */
548
+ async reseed(cancel) {
549
+ // QUIESCE THE DELTA QUEUE FIRST (CTC-114 review round 9) — before anything can open the worker's
550
+ // seed transaction. A reseed can begin while the queue still holds buffered work (a gap escalating
551
+ // mid-replay is the concrete case), and a live queue across `seedBegin` posts `applyChanges` into
552
+ // that open transaction from its retry timer; the nested `BEGIN` rejects, and repeated failures
553
+ // walk the queue into its apply-failed overflow during an otherwise healthy re-seed. That
554
+ // escalation then buys nothing: its `requestResync()` returns immediately because the transport is
555
+ // already resyncing, so live frames are discarded with no recovery. Everything buffered here is
556
+ // pre-snapshot by definition and the snapshot supersedes it, so dropping it is also correct.
557
+ // Unlatched in the `finally` below, on BOTH arms.
558
+ this.deltas.pause();
559
+ // Supersede any seed already running: abort its fetch, then release the worker-side seed session
560
+ // so its SeedReadGate stops holding reads. `seedAbort` is idempotent worker-side.
561
+ this.seedAbort?.abort();
562
+ if (this.seedAbort) {
563
+ try {
564
+ await this.call({ type: "seedAbort" });
565
+ }
566
+ catch {
567
+ // Worker already gone or no session open — the fresh seedBegin below is what matters.
568
+ }
569
+ }
570
+ const gen = ++this.seedGeneration;
571
+ const abort = new AbortController();
572
+ // LINK the transport's cancellation to this seed (CTC-114 review round 10, P1).
573
+ //
574
+ // LiveSyncClient bounds the reseed callback with a TOTAL deadline (default 10 min) that this
575
+ // integration never overrode, while the seed's own bounds — network idleness and per-RPC worker
576
+ // deadlines — are both satisfied indefinitely by a legitimately slow ~100 MB snapshot. So the
577
+ // transport could declare the attempt over and RECONNECT while this method kept streaming into
578
+ // the worker: two writers, with the socket resuming from a cursor the seed was still moving.
579
+ // Frames past that point were accepted by the transport and then dropped by our own paused queue,
580
+ // and if the abandoned seed later committed, the socket sat advanced over a hole that the next
581
+ // delta sealed permanently — unrecoverable without another full re-seed.
582
+ //
583
+ // Honouring the signal is what makes the transport's "give up" mean something here. It aborts the
584
+ // fetch and trips `guardedCall`, so the streaming stops and the cleanup `seedAbort` rolls the
585
+ // worker transaction back. `{ once: true }` because this controller is per-attempt.
586
+ if (cancel) {
587
+ if (cancel.aborted)
588
+ abort.abort();
589
+ else
590
+ cancel.addEventListener("abort", () => abort.abort(), { once: true });
591
+ }
592
+ this.seedAbort = abort;
593
+ const idleMs = this.options.snapshotIdleTimeoutMs ?? DEFAULT_SNAPSHOT_IDLE_TIMEOUT_MS;
594
+ let idleTimer = null;
595
+ const clearIdle = () => {
596
+ if (idleTimer !== null) {
597
+ clearTimeout(idleTimer);
598
+ idleTimer = null;
599
+ }
600
+ };
601
+ // Re-armed on every sign of life, so this measures IDLENESS, not elapsed time — a legitimate
602
+ // ~100 MB snapshot streams for minutes and must not be killed for being big.
603
+ const armIdle = () => {
604
+ if (idleMs <= 0)
605
+ return;
606
+ clearIdle();
607
+ idleTimer = setTimeout(() => abort.abort(), idleMs);
608
+ };
609
+ /** A superseded or aborted seed must post ZERO further worker messages — otherwise its batches
610
+ * interleave with the newer seed's transaction. */
611
+ const guardedCall = (req) => {
612
+ if (this.disposed)
613
+ return Promise.reject(new Error("seed superseded"));
614
+ // A NEWER seed now owns the worker's session. Post NOTHING — not even the cleanup abort, which
615
+ // would roll back the successor's transaction. Its own preamble already aborted this session.
616
+ if (gen !== this.seedGeneration) {
617
+ return Promise.reject(new Error("seed superseded"));
618
+ }
619
+ // Aborted with NO successor (idle timeout, or close() before teardown): refuse everything
620
+ // EXCEPT `seedAbort`. That one MUST still reach the worker — it is what rolls back the open
621
+ // seed transaction and settles the SeedReadGate. Blocking it left the gate armed forever, so
622
+ // every subsequent read hung: precisely the wedge the idle bound exists to prevent, merely
623
+ // relocated from the fetch to the worker. (The boot path masked this — its catch terminates the
624
+ // worker — but a transport-driven resync leaves the worker alive.)
625
+ if (abort.signal.aborted && req.type !== "seedAbort") {
626
+ return Promise.reject(new Error("seed superseded"));
627
+ }
628
+ return this.call(req);
629
+ };
630
+ /**
631
+ * The idle bound measures the NETWORK, and only the network (CTC-114 review round 5).
632
+ *
633
+ * It re-armed on body chunks alone, so it stayed armed across every `seedBatch` RPC and — the
634
+ * sharp case — across the final `seedCommit`, which runs after the body is fully consumed and so
635
+ * can never be re-armed by progress. A commit of a ~100 MB snapshot on slow or background-
636
+ * throttled OPFS that crossed the 30s bound aborted a seed whose stream was perfectly healthy;
637
+ * the post-stream re-check then reported a snapshot that had ALREADY COMMITTED as "seed
638
+ * superseded", so the client threw it away and re-fetched. On a big corpus that can fail to
639
+ * converge at all. This restores what DEFAULT_SNAPSHOT_IDLE_TIMEOUT_MS already documents: "this
640
+ * only fires when the server has genuinely stopped sending."
641
+ *
642
+ * Taking the network timer down leaves the WORKER unbounded on its own — which is why `call()`
643
+ * now applies a separate, much longer deadline to every RPC (rounds 6-9). One number could not
644
+ * serve both: 30s of network silence means a dead server, while 30s inside a `seedCommit` can be
645
+ * honest work. See {@link DEFAULT_WORKER_RPC_TIMEOUT_MS}.
646
+ */
647
+ const seedCall = async (req) => {
648
+ clearIdle();
649
+ try {
650
+ return await guardedCall(req);
651
+ }
652
+ finally {
653
+ // Back to waiting on the body — start a FRESH window rather than resuming a partly-spent one.
654
+ armIdle();
655
+ }
656
+ };
657
+ try {
658
+ armIdle();
659
+ const res = await fetch(snapshotUrl(this.options.baseUrl, this.options.accountId), {
660
+ headers: { accept: "application/x-ndjson" },
661
+ // Same-origin already sends the session cookie, but `baseUrl` may legitimately be an
662
+ // ABSOLUTE same-origin URL, and the default "same-origin" policy is evaluated per request —
663
+ // being explicit costs nothing and removes a class of "works relative, 401s absolute".
664
+ credentials: "include",
665
+ signal: abort.signal,
666
+ });
667
+ armIdle(); // headers arrived
668
+ if (!res.ok)
669
+ throw new Error(`/snapshot ${res.status}`);
670
+ if (!res.body)
671
+ throw new Error("/snapshot returned no body stream");
672
+ // Stream the body into the worker in bounded batches — no whole-body buffer anywhere (CTC-132).
673
+ // `req as never`: `this.call` is generic over the request `type` discriminant, so a widened
674
+ // `ReplicaRequest` isn't assignable to its `Extract<…, {type:K}>` parameter; the worker dispatches
675
+ // on `request.type` at runtime, and every request the helper builds is a genuine ReplicaRequest.
676
+ const cursor = await streamSeedIntoWorker(seedCall, res.body, undefined, armIdle);
677
+ // Re-check before publishing: between the last chunk and here, a close() or a newer reseed may
678
+ // have superseded this one, and writing these cursors would roll a newer baseline backwards.
679
+ if (this.disposed || abort.signal.aborted || gen !== this.seedGeneration) {
680
+ throw new Error("seed superseded");
681
+ }
682
+ this.lastSeq = cursor;
683
+ // The snapshot IS the new baseline for both cursors — anything accepted before it is superseded.
684
+ this.acceptedSeq = cursor;
685
+ this.handlers.onStatus("live");
686
+ this.handlers.onChanged();
687
+ return cursor;
688
+ }
689
+ finally {
690
+ clearIdle();
691
+ if (this.seedAbort === abort)
692
+ this.seedAbort = null;
693
+ // UNLATCH on both arms (round 9), pairing the `pause()` this method opened with. It lives in the
694
+ // `finally` so a FAILED seed also releases the queue — the worker rolled back to the prior
695
+ // complete snapshot and the durable cursor never moved, so live frames may resume against it.
696
+ // Still before reseed() returns, which is what matters: this is the callback the transport
697
+ // awaits BEFORE reopening the socket, so the latch clears before any frame can be delivered —
698
+ // on the server-`{type:"resync"}` path too, which never passes through the overflow handler.
699
+ //
700
+ // GENERATION-GUARDED: a superseded seed must NOT unlatch, or it would clear the pause the
701
+ // successor's preamble just set and let stale frames apply into ITS open transaction.
702
+ if (gen === this.seedGeneration)
703
+ this.deltas.resume();
704
+ }
705
+ }
706
+ /**
707
+ * Open the live subscription via the SDK `LiveSyncClient` (same-origin ws(s) `<baseUrl>/connect`,
708
+ * cookie auth). Each ChangeFrame is buffered into the DeltaQueue; a resync re-seeds. The SDK owns
709
+ * reconnect/backoff; the worker side is transport-agnostic.
710
+ */
711
+ subscribe() {
712
+ // A consumer can synchronously call close() from inside an onStatus/onChanged callback — and
713
+ // start() invokes those callbacks on the warm-start path and from reseed(), both of which run
714
+ // BEFORE this. Without this guard that close() lands while `this.live` is still null, so it has
715
+ // nothing to stop, and we then open a self-reconnecting socket that re-fetches /snapshot forever
716
+ // from a document the consumer already tore down.
717
+ if (this.disposed)
718
+ return;
719
+ if (typeof WebSocket === "undefined")
720
+ return; // SSR/tests — no live feed.
721
+ const base = resolveBase(this.options.baseUrl);
722
+ const baseUrl = `${base.origin}${base.pathname.replace(/\/$/, "")}`;
723
+ this.live = new LiveSyncClient({
724
+ baseUrl,
725
+ // Pass `undefined` through, never "". The DO scopes a cookie-authed socket to the session
726
+ // user's own tenant when no account is named; an explicit account is the tenant-switcher path.
727
+ accountId: this.options.accountId,
728
+ // connectPath defaults to "/connect" in the SDK → "…/connect" under baseUrl.
729
+ auth: { kind: "cookie" }, // NEVER a token in the browser (type system forbids leaking one).
730
+ // Resume from the highest seq we have ACCEPTED, not the highest we have COMMITTED (CTC-318) —
731
+ // otherwise a reconnect re-requests the entire undrained buffer and the DO replays it on top of
732
+ // itself. Falls back to the durable OPFS cursor, which is what a cold start has.
733
+ getCursor: () => Math.max(this.acceptedSeq, this.lastSeq),
734
+ // reseed re-runs the snapshot→OPFS seed and returns the fresh (post-reseed) cursor, which the SDK
735
+ // uses for the next {type:"sync"} — so a resync resumes from the fresh head, never 0.
736
+ // Forward the transport's cancellation signal — see reseed()'s preamble (round 10 P1).
737
+ reseed: (signal) => this.reseed(signal),
738
+ // The transport must stay disconnected until THIS seed has unwound (CTC-114 review round 14).
739
+ //
740
+ // Our cleanup is not instantaneous and not short: aborting mid-seed has to unwind a `seedBatch`
741
+ // or `seedCommit` that is already in flight and then land a `seedAbort`, each bounded by
742
+ // `workerRpcTimeoutMs` (120s by default) — a slow OPFS commit legitimately uses that budget. The
743
+ // transport's own default grace is 250ms, so it would have reconnected long before, into a
744
+ // replica whose delta queue is still PAUSED: replayed frames get refused while the transport's
745
+ // `deliveredSeq` advances over them, and the next frame we do accept carries the durable cursor
746
+ // past the gap. Permanent, and invisible.
747
+ //
748
+ // Derived rather than hard-coded, so raising the RPC deadline cannot silently un-fix this. The
749
+ // margin covers the abort round-trip that follows the RPC being unwound.
750
+ cancelCleanupGraceMs: (this.options.workerRpcTimeoutMs ?? DEFAULT_WORKER_RPC_TIMEOUT_MS) +
751
+ CLOSE_GRACE_MS,
752
+ ...(this.options.reseedTimeoutMs === undefined
753
+ ? {}
754
+ : { reseedTimeoutMs: this.options.reseedTimeoutMs }),
755
+ // Buffer + single-flight drain (CTC-318) — NOT a per-frame apply, which had no backpressure and
756
+ // turned each of up to 200k replayed rows into its own clone, RPC, OPFS transaction and full
757
+ // view rebuild.
758
+ onChange: (frame) => {
759
+ this.enqueueDelta(frame);
760
+ },
761
+ onStatus: (status) => {
762
+ if (this.disposed)
763
+ return;
764
+ // SDK lifecycle → the replica's UI signal. "live" → live; "stopped" is our own teardown
765
+ // (no signal); "error" → error; everything else mid-flight → reconnecting.
766
+ if (status === "live")
767
+ this.handlers.onStatus("live");
768
+ else if (status === "error") {
769
+ console.error("[replica] SDK live feed entered error state");
770
+ this.handlers.onStatus("error");
771
+ }
772
+ else if (status !== "stopped")
773
+ this.handlers.onStatus("reconnecting");
774
+ },
775
+ });
776
+ // start() resolves only on stop(); the open socket keeps things alive between deltas. We never await
777
+ // it in the browser — fire it and stop() on teardown.
778
+ void this.live.start();
779
+ }
780
+ /**
781
+ * Buffer one live SDK ChangeFrame for application (CTC-318 — see delta-queue.ts for why it is
782
+ * buffered rather than applied on arrival). The SDK frame's `row` is OPTIONAL (absent/partial on a
783
+ * delete) — coerce to `{}` so the worker's WireChange always has an object (load-bearing for the
784
+ * delete path, which keys off entityId).
785
+ */
786
+ enqueueDelta(frame) {
787
+ if (this.disposed)
788
+ return;
789
+ // Advance the TRANSPORT high-water the instant the frame is accepted — before it is APPLIED — so a
790
+ // reconnect resumes from what we received rather than from what the worker has committed.
791
+ //
792
+ // But only if the queue actually KEPT it (CTC-114 review round 11). This advanced unconditionally,
793
+ // one line above the push that decides, so a frame arriving while the queue was latched — paused
794
+ // for a re-seed, or overflowed — was counted as delivered and then refused. `getCursor()` reports
795
+ // max(acceptedSeq, lastSeq), so the next `{type:"sync", after}` would resume ABOVE it, the gap
796
+ // detector would re-baseline from the same poisoned value and see contiguity, and the next applied
797
+ // batch would carry the durable cursor past the hole. Silent, permanent, re-seed-only recovery.
798
+ //
799
+ // Reachability today rests on `closeSocket()` running before `pause()` on every path that latches
800
+ // while a socket is open — which held when I traced it, but is a proof by call ordering across
801
+ // three modules, and this PR has now had five consecutive rounds where exactly that kind of
802
+ // reasoning was wrong. Following the queue's own decision costs one boolean and needs no proof.
803
+ const kept = this.deltas.push({
804
+ seq: frame.seq,
805
+ entity: frame.entity,
806
+ op: frame.op,
807
+ row: frame.row ?? {},
808
+ entityId: frame.entityId,
809
+ });
810
+ if (kept && frame.seq > this.acceptedSeq)
811
+ this.acceptedSeq = frame.seq;
812
+ }
813
+ /** Read the issues list view from the local replica (buildIssuesView over OPFS). */
814
+ queryIssues(limit, offset) {
815
+ return this.call({ type: "queryIssues", limit, offset });
816
+ }
817
+ /** Read one issue's detail view from the local replica (buildIssueDetail over OPFS). */
818
+ queryIssueDetail(identifier) {
819
+ return this.call({ type: "queryIssueDetail", identifier });
820
+ }
821
+ /** Read the pull-requests view from the local replica (buildPullsView over OPFS). */
822
+ queryPulls(limit, offset) {
823
+ return this.call({ type: "queryPulls", limit, offset });
824
+ }
825
+ /**
826
+ * Tear down: stop the SDK live client, reject any in-flight calls, cooperatively close the DB
827
+ * (releasing the OPFS SyncAccessHandles), then terminate the Worker and release the origin lock.
828
+ * Synchronous by contract (React effect cleanups can't await); the close→terminate tail runs
829
+ * detached but BOUNDED (CLOSE_GRACE_MS), so teardown can neither hang nor leak.
830
+ */
831
+ close() {
832
+ this.disposed = true;
833
+ this.releaseResources();
834
+ }
835
+ /** Reject every in-flight call with `err` and clear the map. */
836
+ rejectAllPending(err) {
837
+ for (const slot of this.pending.values())
838
+ slot.reject(err);
839
+ this.pending.clear();
840
+ }
841
+ /**
842
+ * Latch a worker load/runtime failure: stop the transport, surface the cause to every in-flight and
843
+ * every future call, release the worker + lock, and report "error".
844
+ *
845
+ * Without this a worker that dies before installing its message handler leaves `start()` and every
846
+ * read pending forever, holding the origin's Web Lock — the single most likely first-integration
847
+ * failure for a third party, presenting as an unbounded silent hang.
848
+ */
849
+ /**
850
+ * Bound one in-flight worker RPC. On expiry the worker is declared DEAD, not merely slow.
851
+ *
852
+ * Tearing it down is the point (`failWorker` → `releaseResources`): a worker that has stopped
853
+ * answering is holding an open seed transaction with its `SeedReadGate` armed, and nothing else can
854
+ * settle either — the cleanup `seedAbort` is itself a worker message, so it would join the same
855
+ * queue of replies that are never coming. Releasing the worker also frees the origin-wide Web Lock
856
+ * and the OPFS handles, which would otherwise wedge every sibling tab into "secondary".
857
+ *
858
+ * Double-settle is harmless: failWorker() rejects the pending map entry too, so the underlying call
859
+ * rejects moments later against an already-settled promise.
860
+ */
861
+ withWorkerDeadline(label, call) {
862
+ const ms = this.options.workerRpcTimeoutMs ?? DEFAULT_WORKER_RPC_TIMEOUT_MS;
863
+ if (ms <= 0)
864
+ return call;
865
+ const seedGated = SEED_GATED_WORKER_RPCS.has(label);
866
+ return new Promise((resolve, reject) => {
867
+ let timer;
868
+ const expire = () => {
869
+ // A READ legitimately waits behind the SeedReadGate for as long as a seed runs. Re-arm rather
870
+ // than fire, so the window measures time the worker could actually have answered in. The seed
871
+ // itself is bounded (network idle + its own RPC deadlines), so this cannot re-arm forever.
872
+ if (seedGated && this.seedAbort) {
873
+ timer = setTimeout(expire, ms);
874
+ return;
875
+ }
876
+ const err = new Error(`replica worker did not answer '${label}' within ${ms}ms — treating it as wedged`);
877
+ this.failWorker(err);
878
+ reject(err);
879
+ };
880
+ timer = setTimeout(expire, ms);
881
+ call.then((v) => {
882
+ clearTimeout(timer);
883
+ resolve(v);
884
+ }, (e) => {
885
+ clearTimeout(timer);
886
+ reject(e instanceof Error ? e : new Error(String(e)));
887
+ });
888
+ });
889
+ }
890
+ failWorker(err) {
891
+ if (this.workerError)
892
+ return; // first cause wins; teardown is idempotent but the message is not
893
+ this.workerError = err;
894
+ console.error("[replica] worker failed:", err);
895
+ this.releaseResources();
896
+ if (!this.disposed)
897
+ this.handlers.onStatus("error");
898
+ }
899
+ /**
900
+ * Release the worker, the origin lock, the transport and the queue — WITHOUT marking the client
901
+ * disposed.
902
+ *
903
+ * Deliberately non-terminal. `start()` is one-shot, so this is not a "retry on this instance" hook;
904
+ * its job is to make sure a failed boot does not hold the origin-wide Web Lock or the OPFS handles
905
+ * hostage from SIBLING TABS and from the next instance the consumer constructs.
906
+ */
907
+ releaseResources() {
908
+ this.live?.stop();
909
+ this.live = null;
910
+ // Abort an in-flight seed so its fetch stops draining the ~100 MB body server-side.
911
+ this.seedAbort?.abort();
912
+ this.seedAbort = null;
913
+ // Drop any deltas still buffered — each retains a full parsed row (issues carry the provider's
914
+ // `raw` JSON), so a queue left behind on teardown would be retained until the client is collected.
915
+ this.deltas.stop();
916
+ this.rejectAllPending(this.workerError ?? new Error("replica client closed"));
917
+ const worker = this.worker;
918
+ this.worker = null;
919
+ const lock = this.lock;
920
+ this.lock = null;
921
+ if (!worker) {
922
+ lock?.release();
923
+ return;
924
+ }
925
+ // Cooperative close first (frees the SAHPool handles), terminate as the bounded backstop. The lock
926
+ // is held until the worker is gone so a sibling tab's boot can't race the not-yet-released pool;
927
+ // browser-lock's retry covers the residual gap on abrupt teardown.
928
+ let settled = false;
929
+ const finish = () => {
930
+ if (settled)
931
+ return;
932
+ settled = true;
933
+ worker.terminate();
934
+ lock?.release();
935
+ };
936
+ try {
937
+ const id = this.nextId++;
938
+ worker.addEventListener("message", (e) => {
939
+ if (e.data.id === id)
940
+ finish();
941
+ });
942
+ const envelope = { id, request: { type: "close" } };
943
+ worker.postMessage(envelope);
944
+ }
945
+ catch {
946
+ finish();
947
+ return;
948
+ }
949
+ setTimeout(finish, CLOSE_GRACE_MS);
950
+ }
951
+ }
952
+ //# sourceMappingURL=browser-replica.js.map