@catalyst-cloud/sdk 0.7.0 → 0.8.1
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/README.md +92 -0
- package/dist/browser.d.ts +9 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +24 -0
- package/dist/browser.js.map +1 -0
- package/dist/live-sync-client.d.ts +128 -9
- package/dist/live-sync-client.d.ts.map +1 -1
- package/dist/live-sync-client.js +369 -51
- package/dist/live-sync-client.js.map +1 -1
- package/dist/replica/browser/apply.d.ts +29 -0
- package/dist/replica/browser/apply.d.ts.map +1 -0
- package/dist/replica/browser/apply.js +59 -0
- package/dist/replica/browser/apply.js.map +1 -0
- package/dist/replica/browser/browser-lock.d.ts +25 -0
- package/dist/replica/browser/browser-lock.d.ts.map +1 -0
- package/dist/replica/browser/browser-lock.js +91 -0
- package/dist/replica/browser/browser-lock.js.map +1 -0
- package/dist/replica/browser/browser-replica.d.ts +237 -0
- package/dist/replica/browser/browser-replica.d.ts.map +1 -0
- package/dist/replica/browser/browser-replica.js +952 -0
- package/dist/replica/browser/browser-replica.js.map +1 -0
- package/dist/replica/browser/db.worker.d.ts +2 -0
- package/dist/replica/browser/db.worker.d.ts.map +1 -0
- package/dist/replica/browser/db.worker.js +40 -0
- package/dist/replica/browser/db.worker.js.map +1 -0
- package/dist/replica/browser/delta-queue.d.ts +187 -0
- package/dist/replica/browser/delta-queue.d.ts.map +1 -0
- package/dist/replica/browser/delta-queue.js +328 -0
- package/dist/replica/browser/delta-queue.js.map +1 -0
- package/dist/replica/browser/ports.d.ts +39 -0
- package/dist/replica/browser/ports.d.ts.map +1 -0
- package/dist/replica/browser/ports.js +144 -0
- package/dist/replica/browser/ports.js.map +1 -0
- package/dist/replica/browser/protocol.d.ts +129 -0
- package/dist/replica/browser/protocol.d.ts.map +1 -0
- package/dist/replica/browser/protocol.js +12 -0
- package/dist/replica/browser/protocol.js.map +1 -0
- package/dist/replica/browser/seed-read-gate.d.ts +13 -0
- package/dist/replica/browser/seed-read-gate.d.ts.map +1 -0
- package/dist/replica/browser/seed-read-gate.js +36 -0
- package/dist/replica/browser/seed-read-gate.js.map +1 -0
- package/dist/replica/browser/seed-session.d.ts +23 -0
- package/dist/replica/browser/seed-session.d.ts.map +1 -0
- package/dist/replica/browser/seed-session.js +57 -0
- package/dist/replica/browser/seed-session.js.map +1 -0
- package/dist/replica/browser/snapshot-stream.d.ts +24 -0
- package/dist/replica/browser/snapshot-stream.d.ts.map +1 -0
- package/dist/replica/browser/snapshot-stream.js +113 -0
- package/dist/replica/browser/snapshot-stream.js.map +1 -0
- package/dist/replica/browser/sqlite-db.d.ts +9 -0
- package/dist/replica/browser/sqlite-db.d.ts.map +1 -0
- package/dist/replica/browser/sqlite-db.js +28 -0
- package/dist/replica/browser/sqlite-db.js.map +1 -0
- package/dist/replica/browser/support.d.ts +2 -0
- package/dist/replica/browser/support.d.ts.map +1 -0
- package/dist/replica/browser/support.js +30 -0
- package/dist/replica/browser/support.js.map +1 -0
- package/dist/replica/browser/validate.d.ts +17 -0
- package/dist/replica/browser/validate.d.ts.map +1 -0
- package/dist/replica/browser/validate.js +47 -0
- package/dist/replica/browser/validate.js.map +1 -0
- package/dist/replica/browser/worker-core.d.ts +10 -0
- package/dist/replica/browser/worker-core.d.ts.map +1 -0
- package/dist/replica/browser/worker-core.js +181 -0
- package/dist/replica/browser/worker-core.js.map +1 -0
- package/dist/replica/catalyst-replica.d.ts +6 -9
- package/dist/replica/catalyst-replica.d.ts.map +1 -1
- package/dist/replica/catalyst-replica.js +53 -19
- package/dist/replica/catalyst-replica.js.map +1 -1
- package/dist/replica/migration-shape.d.ts +11 -0
- package/dist/replica/migration-shape.d.ts.map +1 -0
- package/dist/replica/migration-shape.js +25 -0
- package/dist/replica/migration-shape.js.map +1 -0
- 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
|