instant_record 0.1.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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +378 -0
  5. data/app/controllers/instant_record/bootstraps_controller.rb +34 -0
  6. data/app/controllers/instant_record/events_controller.rb +59 -0
  7. data/app/controllers/instant_record/mutations_controller.rb +21 -0
  8. data/app/controllers/instant_record/records_controller.rb +46 -0
  9. data/app/models/instant_record/applied_mutation.rb +9 -0
  10. data/app/models/instant_record/change.rb +30 -0
  11. data/app/models/instant_record/mutation_applier.rb +133 -0
  12. data/config/routes.rb +7 -0
  13. data/db/migrate/20260721000002_create_instant_record_outbox.rb +12 -0
  14. data/db/migrate/20260721000003_create_instant_record_sync_metadata.rb +9 -0
  15. data/db/migrate/20260721000101_create_instant_record_changes.rb +13 -0
  16. data/db/migrate/20260721000102_create_instant_record_applied_mutations.rb +10 -0
  17. data/lib/generators/instant_record/install/install_generator.rb +53 -0
  18. data/lib/generators/instant_record/install/templates/database.js +32 -0
  19. data/lib/generators/instant_record/install/templates/index.html +69 -0
  20. data/lib/generators/instant_record/install/templates/rails.sw.js +750 -0
  21. data/lib/instant_record/client/notifier.rb +23 -0
  22. data/lib/instant_record/client/transport.rb +90 -0
  23. data/lib/instant_record/client.rb +465 -0
  24. data/lib/instant_record/configuration.rb +19 -0
  25. data/lib/instant_record/engine.rb +52 -0
  26. data/lib/instant_record/local_schema.rb +75 -0
  27. data/lib/instant_record/outbox_mutation.rb +24 -0
  28. data/lib/instant_record/pglite_compat.rb +40 -0
  29. data/lib/instant_record/runtime_scoped.rb +21 -0
  30. data/lib/instant_record/sync_metadata.rb +16 -0
  31. data/lib/instant_record/sync_window.rb +82 -0
  32. data/lib/instant_record/syncable.rb +106 -0
  33. data/lib/instant_record/version.rb +3 -0
  34. data/lib/instant_record.rb +356 -0
  35. data/lib/tasks/instant_record.rake +53 -0
  36. metadata +108 -0
@@ -0,0 +1,750 @@
1
+ import {
2
+ initRailsVM,
3
+ Progress,
4
+ registerPGliteWasmInterface,
5
+ RackHandler,
6
+ } from "wasmify-rails";
7
+
8
+ import { setupPGliteDatabase } from "./database.js";
9
+
10
+ let db = null;
11
+
12
+ const initDB = async (progress) => {
13
+ if (db) return db;
14
+
15
+ bootStarted();
16
+ const startedAt = performance.now();
17
+
18
+ progress?.updateStep("Initializing PGlite database...");
19
+ db = await setupPGliteDatabase();
20
+ progress?.updateStep("PGlite database created.");
21
+
22
+ bootPhase("pglite", { ms: since(startedAt) });
23
+
24
+ return db;
25
+ };
26
+
27
+ let vm = null;
28
+
29
+ // --- Inspection and simulation ----------------------------------------------
30
+ // Ruby's transport reaches the network through globalThis.fetch (see
31
+ // Client::Transport::JsFetch), so wrapping it here is the one place that sees
32
+ // every request the sync loop makes to the server — and the one place that can
33
+ // delay or fail those requests without the sync code knowing. A page turns this
34
+ // on by posting instant_record.simulate; until then it is inert.
35
+
36
+ const sim = { report: false, offline: false, latencyMs: 0 };
37
+
38
+ // Kept here, not in the page: in a plain-Rails app every interaction is a
39
+ // navigation, which wipes any page-side buffer and races the message announcing
40
+ // the navigation itself. The worker outlives all of that, so a page that just
41
+ // loaded can ask for what it missed.
42
+ const REPORT_HISTORY = 150;
43
+ const history = [];
44
+
45
+ const report = (entry) => {
46
+ const stamped = { ...entry, at: Date.now() };
47
+ history.push(stamped);
48
+ if (history.length > REPORT_HISTORY) history.shift();
49
+
50
+ if (!sim.report) return;
51
+
52
+ self.clients.matchAll({ type: "window" }).then((clients) => {
53
+ clients.forEach((client) =>
54
+ client.postMessage({ type: "instant_record.debug", entry: stamped }),
55
+ );
56
+ });
57
+ };
58
+
59
+ // Assets arrive as a burst after every navigation. Worth knowing that the
60
+ // bundle serves its own asset pipeline — but once per page, as a total, not as
61
+ // a dozen lines that bury the sync flow. (They are re-requested each load
62
+ // rather than cached, which is why the burst repeats.)
63
+ const ASSET_BATCH_MS = 250;
64
+
65
+ let assetBatch = null;
66
+
67
+ const reportAssets = (ms) => {
68
+ if (!sim.report) return;
69
+
70
+ assetBatch ||= { count: 0, ms: 0, timer: null };
71
+ assetBatch.count += 1;
72
+ assetBatch.ms += ms;
73
+
74
+ clearTimeout(assetBatch.timer);
75
+ assetBatch.timer = setTimeout(() => {
76
+ const { count, ms: total } = assetBatch;
77
+ assetBatch = null;
78
+ report({
79
+ kind: "assets",
80
+ path: `${count} served from the bundle`,
81
+ ms: total,
82
+ });
83
+ }, ASSET_BATCH_MS);
84
+ };
85
+
86
+ const nativeFetch = globalThis.fetch.bind(globalThis);
87
+
88
+ globalThis.fetch = async (input, init) => {
89
+ const href = typeof input === "string" ? input : input.url;
90
+ const url = new URL(href, self.location.origin);
91
+
92
+ // Only requests to the sync server are simulated. The app's own bundle and
93
+ // assets are same-origin and must keep loading even while "offline" — going
94
+ // offline means losing the server, not the local runtime.
95
+ if (url.origin === self.location.origin) return nativeFetch(input, init);
96
+
97
+ const method = (
98
+ init?.method || (typeof input === "object" && input.method) || "GET"
99
+ ).toUpperCase();
100
+ const startedAt = performance.now();
101
+ const elapsed = () => Math.round(performance.now() - startedAt);
102
+
103
+ if (sim.latencyMs) {
104
+ await new Promise((resolve) => setTimeout(resolve, sim.latencyMs));
105
+ }
106
+
107
+ if (sim.offline) {
108
+ report({ kind: "sync", method, path: url.pathname, status: "offline" });
109
+ throw new TypeError("Failed to fetch (simulated offline)");
110
+ }
111
+
112
+ try {
113
+ const response = await nativeFetch(input, init);
114
+ report({ kind: "sync", method, path: url.pathname, status: response.status, ms: elapsed() });
115
+ return response;
116
+ } catch (error) {
117
+ report({ kind: "sync", method, path: url.pathname, status: "failed", ms: elapsed() });
118
+ throw error;
119
+ }
120
+ };
121
+
122
+ // --- Carrying writes up ------------------------------------------------------
123
+ // No heartbeat. A sync pass runs when there is a reason for one: a local write
124
+ // just happened, the app just booted, or the network came back. The only reason
125
+ // to come back unprompted is an outbox that still has something in it, so the
126
+ // pass reports what is left and we retry with backoff until it is empty. An idle
127
+ // client makes no requests at all beyond holding the change stream open.
128
+
129
+ const DRAIN_BACKOFF_CAP_MS = 30000;
130
+ // How long a burst is given to finish before one pass carries all of it. Short
131
+ // enough to be imperceptible — the write itself already rendered locally.
132
+ const SYNC_QUIET_MS = 50;
133
+
134
+ let drainTimer = null;
135
+ let drainBaseMs = 3000; // replaced with config.sync_interval once the VM is up
136
+ let drainDelayMs = drainBaseMs;
137
+
138
+ const syncNow = async () => {
139
+ if (!vm) return;
140
+
141
+ clearTimeout(drainTimer);
142
+ drainTimer = null;
143
+
144
+ const startedAt = performance.now();
145
+ const reply = await enterVM("InstantRecord.tick_json");
146
+ const ms = Math.round(performance.now() - startedAt);
147
+
148
+ if (reply.error) console.warn("[instant-record] sync pass failed", reply.error);
149
+
150
+ // Nothing queued: stop. This is what makes an idle tab silent.
151
+ const pending = reply.pending ?? 0;
152
+ if (reply.ok && pending === 0) {
153
+ drainDelayMs = drainBaseMs;
154
+ report({ kind: "sync-pass", path: "outbox empty, idle", ms });
155
+ return;
156
+ }
157
+
158
+ report({
159
+ kind: "sync-pass",
160
+ path: `${pending} queued, retrying in ${Math.round(drainDelayMs / 1000)}s`,
161
+ status: reply.ok ? undefined : "failed",
162
+ ms,
163
+ });
164
+
165
+ drainTimer = setTimeout(syncNow, drainDelayMs);
166
+ drainDelayMs = Math.min(drainDelayMs * 2, DRAIN_BACKOFF_CAP_MS);
167
+ };
168
+
169
+ // A fresh reason to sync resets the backoff — the previous delay was chosen for
170
+ // a failure that a new write or a reconnect has no bearing on.
171
+ //
172
+ // The short wait is this shim's only job here: Ruby coalesces requests that
173
+ // arrive while a pass is running (see InstantRecord::COALESCE_PASSES), but it
174
+ // cannot sleep to wait for ones that arrive *between* passes, because sleeping
175
+ // would block the VM. So a burst of writes settles into one pass rather than one
176
+ // pass each, and the batched drain then carries the whole burst in one request.
177
+ const syncSoon = () => {
178
+ drainDelayMs = drainBaseMs;
179
+ clearTimeout(drainTimer);
180
+ drainTimer = setTimeout(syncNow, SYNC_QUIET_MS);
181
+ };
182
+
183
+ const startSync = async () => {
184
+ // Boot-time window eviction runs only on cold boots. An idle-terminated
185
+ // worker restarting under already-open tabs (which it still controls) must
186
+ // not trim scrollback a reader is holding.
187
+ const controlled = await self.clients.matchAll({ type: "window" });
188
+ const coldBoot = controlled.length === 0;
189
+ await vm.evalAsync(`InstantRecord.start(cold_boot: ${coldBoot})`);
190
+
191
+ const seconds = parseInt(
192
+ (await vm.evalAsync("InstantRecord.config.sync_interval")).toString(),
193
+ 10,
194
+ );
195
+
196
+ drainBaseMs = seconds * 1000;
197
+ drainDelayMs = drainBaseMs;
198
+
199
+ // The one unprompted pass: hydrates a fresh client, trims windows on a cold
200
+ // boot, and clears anything an earlier session left queued.
201
+ syncNow();
202
+
203
+ // Not awaited: holds a stream for as long as the VM lives.
204
+ streamChanges();
205
+ };
206
+
207
+ // --- The change stream -------------------------------------------------------
208
+ // Ruby cannot hold a tailing stream: every chunk it awaited would suspend the
209
+ // single-flight guard, starving outbox drains for the whole window. That is why
210
+ // its own poll asks for window=0. The worker has no such problem — it is plain
211
+ // JavaScript — so it holds the stream here and hands batches to Ruby. Inbound
212
+ // delivery is push, and reopening with ?after=<cursor> replays anything missed,
213
+ // which is why nothing needs to poll for changes any more.
214
+
215
+ const STREAM_RETRY_MS = 2000;
216
+
217
+ let streaming = false;
218
+ // Going offline has to sever the connection that is already open, not just fail
219
+ // the next one — a held stream would otherwise keep delivering for the rest of
220
+ // its window and make the simulation look broken.
221
+ let streamAbort = null;
222
+
223
+ // Enter the VM for one guarded, JSON-returning call, retrying while a sync pass
224
+ // holds the single-flight guard. Every entry point the worker uses answers
225
+ // {busy: true} rather than re-entering.
226
+ const enterVM = async (expression, attempts = 8) => {
227
+ for (let attempt = 0; attempt < attempts; attempt++) {
228
+ let reply;
229
+ try {
230
+ reply = JSON.parse((await vm.evalAsync(expression)).toString());
231
+ } catch (e) {
232
+ return { ok: false, error: String(e) };
233
+ }
234
+ if (!reply.busy) return reply;
235
+ await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
236
+ }
237
+ return { ok: false, busy: true };
238
+ };
239
+
240
+ const runStream = async () => {
241
+ const endpoint = (await vm.evalAsync("InstantRecord.endpoint")).toString();
242
+ const cursor = (await vm.evalAsync("InstantRecord.cursor")).toString();
243
+ // Deliberately the wrapped fetch: the stream is a sync-server request, so
244
+ // "offline" must break it like any other and let the poll take over.
245
+ streamAbort = new AbortController();
246
+ const response = await fetch(`${endpoint}/events?after=${cursor}`, {
247
+ signal: streamAbort.signal,
248
+ });
249
+ if (!response.ok) throw new Error(`stream -> ${response.status}`);
250
+
251
+ // Also discards any half-received frame from the connection that just died.
252
+ await enterVM("InstantRecord.stream_opened_json");
253
+ streaming = true;
254
+ report({ kind: "stream", path: `connected, tailing from ${cursor}`, streamOpen: true });
255
+
256
+ // Reaching the server proves the network is back, which is the other reason a
257
+ // stalled outbox deserves an immediate retry rather than waiting out a backoff.
258
+ syncSoon();
259
+
260
+ const reader = response.body.getReader();
261
+ const decoder = new TextDecoder();
262
+
263
+ // Read bytes, hand over text. Framing the SSE wire format is Ruby's job — it
264
+ // already has a parser for it (Transport::SseParser), and a second one here
265
+ // would be an untested copy free to drift. A frame split across chunks is
266
+ // buffered on that side until it is whole.
267
+ for (;;) {
268
+ const { value, done } = await reader.read();
269
+ if (done) break;
270
+
271
+ const chunk = decoder.decode(value, { stream: true });
272
+ if (!chunk) continue;
273
+
274
+ const encoded = btoa(unescape(encodeURIComponent(chunk)));
275
+ const consumed = await enterVM(`InstantRecord.consume_stream_b64("${encoded}")`);
276
+ if (!consumed.applied) continue;
277
+
278
+ report({
279
+ kind: "stream",
280
+ path: `${consumed.applied} pushed → applied, cursor ${consumed.cursor}`,
281
+ status: consumed.ok ? 200 : "failed",
282
+ });
283
+ }
284
+ };
285
+
286
+ // One stream at a time, reopened for as long as the VM is alive. A failure just
287
+ // means the tick's poll is the delivery path until the next attempt succeeds.
288
+ const streamChanges = async () => {
289
+ for (;;) {
290
+ if (!vm) return;
291
+
292
+ try {
293
+ await runStream();
294
+ report({ kind: "stream", path: "window closed, reopening", streamOpen: false });
295
+ } catch (error) {
296
+ report({
297
+ kind: "stream",
298
+ path: "unavailable — polling instead",
299
+ status: "failed",
300
+ streamOpen: false,
301
+ });
302
+ await new Promise((resolve) => setTimeout(resolve, STREAM_RETRY_MS));
303
+ } finally {
304
+ if (streaming) {
305
+ streaming = false;
306
+ await enterVM("InstantRecord.stream_closed_json");
307
+ }
308
+ }
309
+ }
310
+ };
311
+
312
+ // History fetches enter the VM here — via a page message, never from inside a
313
+ // Rack request — because a nested evalAsync at an asyncify suspension point
314
+ // is the known crash class. Ruby's single-flight guard answers busy while a
315
+ // sync tick is in flight; retry briefly instead of interleaving.
316
+ const fetchHistory = async (request, attempts = 8) => {
317
+ if (!vm) return { ok: false, error: "vm not ready" };
318
+
319
+ // Base64 the request before it enters Ruby source: the base64 alphabet
320
+ // has none of Ruby's string-literal metacharacters, so an untrusted page
321
+ // message can't break out of the string or trigger #{} interpolation.
322
+ // Passing raw JSON here would be a code-injection sink.
323
+ const encoded = btoa(
324
+ unescape(encodeURIComponent(JSON.stringify(request))),
325
+ );
326
+
327
+ const startedAt = performance.now();
328
+
329
+ for (let attempt = 0; attempt < attempts; attempt++) {
330
+ let reply;
331
+ try {
332
+ const raw = await vm.evalAsync(
333
+ `InstantRecord.fetch_history_b64("${encoded}")`,
334
+ );
335
+ reply = JSON.parse(raw.toString());
336
+ } catch (e) {
337
+ report({ kind: "history", path: "fetch_history", status: "failed" });
338
+ return { ok: false, error: String(e) };
339
+ }
340
+ if (!reply.busy) {
341
+ report({
342
+ kind: "history",
343
+ path: `fetch_history → ${reply.applied ?? 0} applied, more: ${!!reply.has_more}`,
344
+ status: reply.ok ? 200 : "failed",
345
+ ms: Math.round(performance.now() - startedAt),
346
+ });
347
+ return reply;
348
+ }
349
+ report({ kind: "history", path: "fetch_history → busy, retrying", status: "busy" });
350
+ await new Promise((resolve) => setTimeout(resolve, 150 * (attempt + 1)));
351
+ }
352
+
353
+ // Retries exhausted while a sync pass held the VM: transient, not offline.
354
+ return { ok: false, busy: true, error: "sync busy" };
355
+ };
356
+
357
+ // --- The boot ledger ---------------------------------------------------------
358
+ // A first boot downloads ~60MB of Ruby, compiles it, opens a Postgres, boots
359
+ // Rails and prepares a schema. Behind one line of splash copy that wait reads as
360
+ // broken; itemised, it reads as expensive and understood. So each phase is
361
+ // timed here and announced as it lands.
362
+ //
363
+ // This is the browser's own instrumentation, not sync logic: the worker is the
364
+ // only place that sees the bundle arrive and the VM come up. It reports numbers
365
+ // only — the words belong to the page. Nothing is estimated: a figure this side
366
+ // cannot measure is sent as null, and the page leaves that line out.
367
+
368
+ // Only this build produces ledger messages, and it says so out loud. An edited
369
+ // worker can fail to reach the browser silently (the dev worker's URL never
370
+ // changes), so anything measuring the worker can check what it is talking to
371
+ // instead of assuming.
372
+ const LEDGER_VERSION = "boot-ledger-1";
373
+
374
+ const boot = { at: null, startedAt: null, entries: [], totalMs: null };
375
+
376
+ const announce = async (message) => {
377
+ // includeUncontrolled: during install this worker controls nothing yet, and
378
+ // the splash waiting on it is precisely the client that needs these.
379
+ const clients = await self.clients.matchAll({
380
+ includeUncontrolled: true,
381
+ type: "window",
382
+ });
383
+ clients.forEach((client) => client.postMessage(message));
384
+ };
385
+
386
+ // Whichever entry point gets there first owns the clock: install boots the VM,
387
+ // but so does the first Rack request after an idle worker was terminated.
388
+ const bootStarted = () => {
389
+ if (boot.startedAt !== null) return;
390
+
391
+ boot.at = Date.now();
392
+ boot.startedAt = performance.now();
393
+ boot.entries = [];
394
+ boot.totalMs = null;
395
+ };
396
+
397
+ const since = (mark) => Math.round(performance.now() - mark);
398
+
399
+ const bootPhase = (phase, detail) => {
400
+ const entry = { phase, ...detail };
401
+ boot.entries.push(entry);
402
+ announce({ type: "instant_record.boot_phase", entry, ledger: LEDGER_VERSION });
403
+ };
404
+
405
+ const bootLedger = () => ({
406
+ ledger: LEDGER_VERSION,
407
+ build: BUILD_VERSION,
408
+ at: boot.at,
409
+ entries: boot.entries,
410
+ totalMs: boot.totalMs,
411
+ });
412
+
413
+ const initVM = async (progress, opts = {}) => {
414
+ if (vm) return vm;
415
+
416
+ bootStarted();
417
+
418
+ if (!db) {
419
+ await initDB(progress);
420
+ }
421
+
422
+ registerPGliteWasmInterface(self, db);
423
+
424
+ let redirectConsole = true;
425
+
426
+ // Fetch with revalidation: the browser happily serves a cached app.wasm to
427
+ // a freshly installed service worker, pinning clients to a stale bundle
428
+ // after a rebuild. `no-cache` revalidates via ETag — a 304 when unchanged,
429
+ // fresh bytes after a deploy.
430
+ progress?.updateStep("Loading WebAssembly module...");
431
+ const wasmUrl = new URL("/app.wasm", self.location.origin).href;
432
+ const requestedAt = performance.now();
433
+ const response = await fetch(wasmUrl, { cache: "no-cache" });
434
+
435
+ // Count the bytes on their way into the compiler rather than trusting a size
436
+ // written down at build time — that number drifts, this one cannot. The
437
+ // stream still feeds compileStreaming, so compiling continues to overlap the
438
+ // download and the transform costs nothing measurable.
439
+ let bytes = 0;
440
+ let lastByteAt = null;
441
+ const counted = response.body.pipeThrough(
442
+ new TransformStream({
443
+ transform(chunk, controller) {
444
+ bytes += chunk.byteLength;
445
+ controller.enqueue(chunk);
446
+ },
447
+ flush() {
448
+ lastByteAt = performance.now();
449
+ },
450
+ }),
451
+ );
452
+
453
+ const wasmModule = await WebAssembly.compileStreaming(
454
+ new Response(counted, { headers: { "content-type": "application/wasm" } }),
455
+ );
456
+ const compiledAt = performance.now();
457
+
458
+ // Whether those bytes crossed the network is the browser's own accounting,
459
+ // not something to infer: `transferSize` counts what came over the wire, so
460
+ // 0 is a cache hit and header-sized is a revalidated one. `no-cache` above
461
+ // means even a warm boot re-asks, which is why the comparison is against
462
+ // encodedBodySize rather than zero. Resource timing is not guaranteed inside
463
+ // a worker — without it, cached stays null and the page says nothing.
464
+ const timing = performance.getEntriesByName(wasmUrl).pop();
465
+ const wireBytes = timing ? timing.encodedBodySize || null : null;
466
+ const cached = timing
467
+ ? timing.transferSize === 0 ||
468
+ (timing.encodedBodySize > 0 && timing.transferSize < timing.encodedBodySize)
469
+ : null;
470
+
471
+ bootPhase("bundle", {
472
+ ms: Math.round((lastByteAt ?? compiledAt) - requestedAt),
473
+ bytes,
474
+ wireBytes,
475
+ cached,
476
+ });
477
+
478
+ // Streaming compilation finishes shortly after the last byte; that tail is
479
+ // the only part of the compile that is not hidden behind the download.
480
+ bootPhase("compile", { ms: Math.round(compiledAt - (lastByteAt ?? requestedAt)) });
481
+
482
+ const vmStartedAt = performance.now();
483
+
484
+ vm = await initRailsVM(wasmModule, {
485
+ database: { adapter: "pglite" },
486
+ async: true,
487
+ progressCallback: (step) => {
488
+ progress?.updateStep(step);
489
+ },
490
+ outputCallback: (output) => {
491
+ if (!redirectConsole) return;
492
+ progress?.notify(output);
493
+ },
494
+ ...opts,
495
+ });
496
+
497
+ bootPhase("vm", { ms: since(vmStartedAt) });
498
+
499
+ // Ensure schema is loaded (PGlite is async-only, so evalAsync). Not a bare
500
+ // DatabaseTasks.prepare_all: the app's migrations are invisible from this VM's
501
+ // working directory, so catching an existing local database up to a schema
502
+ // that has grown since is Ruby's job — InstantRecord::LocalSchema.
503
+ const schemaStartedAt = performance.now();
504
+ progress?.updateStep("Preparing database...");
505
+ await vm.evalAsync("InstantRecord.prepare_database!");
506
+
507
+ bootPhase("schema", { ms: since(schemaStartedAt) });
508
+
509
+ boot.totalMs = Math.round(performance.now() - boot.startedAt);
510
+ console.log(
511
+ `[instant-record] boot ${boot.totalMs}ms (build ${BUILD_VERSION}, ${LEDGER_VERSION})`,
512
+ );
513
+ progress?.notify(`Rails VM boot + db prepare: ${boot.totalMs}ms`);
514
+ announce({ type: "instant_record.boot_done", ledger: bootLedger() });
515
+
516
+ redirectConsole = false;
517
+
518
+ await startSync();
519
+
520
+ return vm;
521
+ };
522
+
523
+ const resetVM = () => {
524
+ clearTimeout(drainTimer);
525
+ drainTimer = null;
526
+ // The stream loop and any pending retry both bail once the VM is gone.
527
+ vm = null;
528
+ // The next boot is a boot of its own, and gets its own ledger.
529
+ boot.startedAt = null;
530
+ };
531
+
532
+ // Half of a cold boot on demand — measuring one used to mean the DevTools
533
+ // "clear site data" ritual, so it was never repeated.
534
+ //
535
+ // This side lets go of everything: the held change stream (a pending fetch keeps
536
+ // this worker alive, and a live worker is what blocks the database delete), the
537
+ // PGlite handle, the caches, and finally the registration, so the page that
538
+ // reloads next comes back uncontrolled and installs a worker from scratch.
539
+ //
540
+ // Deleting the database itself is deliberately not done here. IndexedDB will not
541
+ // delete a database while anything holds it open, and closing PGlite does not
542
+ // release the handle its wasm filesystem keeps — only this worker's death does.
543
+ // So the page deletes it on the next load, once this worker is gone; a page that
544
+ // does not bother still gets a fresh worker on the data that is already there.
545
+ // What nothing can clear is the browser's own HTTP cache, which may still hold
546
+ // app.wasm — hence the ledger measuring where the bundle came from instead of
547
+ // assuming a wipe implies a download.
548
+ const wipeLocalData = async () => {
549
+ streamAbort?.abort();
550
+ resetVM();
551
+
552
+ const open = db;
553
+ db = null;
554
+ await open?.close();
555
+
556
+ await Promise.all((await caches.keys()).map((key) => caches.delete(key)));
557
+ await self.registration.unregister();
558
+ };
559
+
560
+ const installApp = async () => {
561
+ const progress = new Progress();
562
+ await progress.attach(self);
563
+
564
+ await initDB(progress);
565
+ await initVM(progress);
566
+ };
567
+
568
+ // Stamped by instant_record:build with the app.wasm digest. A rebuild
569
+ // changes this constant, which changes the service worker's bytes, which
570
+ // makes the browser install the new worker on the next navigation — that is
571
+ // the whole update mechanism for already-installed clients.
572
+ const BUILD_VERSION = "__INSTANT_RECORD_BUILD_VERSION__";
573
+
574
+ self.addEventListener("activate", (event) => {
575
+ console.log(
576
+ `[rails-web] Activate Service Worker (build ${BUILD_VERSION}, ${LEDGER_VERSION})`,
577
+ );
578
+ // Take over open tabs immediately and reload them onto the new bundle;
579
+ // without this, old tabs keep the previous worker's VM forever.
580
+ event.waitUntil(
581
+ self.clients.claim().then(async () => {
582
+ const clients = await self.clients.matchAll({ type: "window" });
583
+ clients.forEach((client) => client.postMessage({ type: "sw_updated" }));
584
+ }),
585
+ );
586
+ });
587
+
588
+ self.addEventListener("install", (event) => {
589
+ console.log(
590
+ `[rails-web] Install Service Worker (build ${BUILD_VERSION}, ${LEDGER_VERSION})`,
591
+ );
592
+ event.waitUntil(installApp().then(() => self.skipWaiting()));
593
+ });
594
+
595
+ const rackHandler = new RackHandler(initVM, { assumeSSL: true, async: true });
596
+
597
+ const BOOT_RESOURCES = ["/boot", "/boot.js", "/boot.html", "/rails.sw.js", "/favicon.ico"];
598
+ const VITE_RESOURCES = ["node_modules", "@vite"];
599
+ const VITE_ASSETS = ["/assets/pglite-", "/assets/boot-"];
600
+
601
+ self.addEventListener("fetch", (event) => {
602
+ const url = new URL(event.request.url);
603
+
604
+ // Cross-origin requests (e.g. the sync server) go straight to the network.
605
+ if (url.origin !== self.location.origin) {
606
+ return;
607
+ }
608
+
609
+ // Vite's hashed output shares /assets/ with Rails' Propshaft assets, which
610
+ // Rack-in-Wasm serves from the bundle. Match Vite's files by name — a
611
+ // blanket /assets/ bypass 404s every stylesheet and Stimulus controller.
612
+ if (VITE_ASSETS.some((prefix) => url.pathname.startsWith(prefix))) {
613
+ console.log("[rails-web] Fetching Vite asset from network:", url.href);
614
+ event.respondWith(fetch(event.request));
615
+ return;
616
+ }
617
+
618
+ if (BOOT_RESOURCES.some((r) => url.pathname.endsWith(r))) {
619
+ console.log("[rails-web] Fetching boot files from network:", url.href);
620
+ event.respondWith(fetch(event.request.url));
621
+ return;
622
+ }
623
+
624
+ if (VITE_RESOURCES.some((r) => url.href.includes(r))) {
625
+ console.log("[rails-web] Fetching Vite files from network:", url.href);
626
+ event.respondWith(fetch(event.request.url));
627
+ return;
628
+ }
629
+
630
+ // Requests marked ?instant_record_network=1 bypass the local runtime and go
631
+ // to the real server — for actions whose writes must land on the
632
+ // authoritative change log (e.g. a demo reset) even when served same-origin.
633
+ if (url.searchParams.has("instant_record_network")) {
634
+ console.log("[rails-web] Passing request to network:", url.href);
635
+ // fetch(event.request) preserves the method and body (POSTs included).
636
+ event.respondWith(fetch(event.request));
637
+ return;
638
+ }
639
+
640
+ const respond = rackHandler.handle(event.request);
641
+
642
+ // Reported from here rather than from the page, because a form submission and
643
+ // a navigation are not window.fetch calls — wrapping fetch in the page misses
644
+ // the entire plain-Rails interaction model, and the first page load with it.
645
+ //
646
+ if (sim.report) {
647
+ const startedAt = performance.now();
648
+ const isAsset = url.pathname.startsWith("/assets/");
649
+
650
+ event.waitUntil(
651
+ respond
652
+ .then((response) => {
653
+ const ms = Math.round(performance.now() - startedAt);
654
+ if (isAsset) return reportAssets(ms);
655
+
656
+ report({
657
+ kind: "local",
658
+ method: event.request.method,
659
+ path: url.pathname + url.search,
660
+ status: response.status,
661
+ ms,
662
+ });
663
+ })
664
+ .catch(() => {}),
665
+ );
666
+ }
667
+
668
+ // A local write enqueues an outbox mutation, and that is the main reason a
669
+ // sync pass ever runs — there is no timer waiting to notice it.
670
+ if (event.request.method !== "GET") {
671
+ event.waitUntil(respond.then(() => syncSoon()));
672
+ }
673
+
674
+ return event.respondWith(respond);
675
+ });
676
+
677
+ self.addEventListener("message", async (event) => {
678
+ console.log("[rails-web] Received worker message:", event.data);
679
+
680
+ if (event.data.type === "instant_record.fetch_history") {
681
+ const reply = await fetchHistory(event.data.request);
682
+ event.ports[0]?.postMessage(reply);
683
+ return;
684
+ }
685
+
686
+ // A page that was not open for the boot it wants to report on asks for the
687
+ // ledger here — /receipts, or any warm visit where the splash never appeared.
688
+ // totalMs is null while a boot is still running.
689
+ if (event.data.type === "instant_record.boot_ledger") {
690
+ event.source?.postMessage({
691
+ type: "instant_record.boot_done",
692
+ ledger: bootLedger(),
693
+ });
694
+ return;
695
+ }
696
+
697
+ if (event.data.type === "instant_record.wipe_local_data") {
698
+ await wipeLocalData();
699
+ event.ports[0]?.postMessage({ ok: true });
700
+ return;
701
+ }
702
+
703
+ // Clearing has to reach the buffer, not just the page's copy of it.
704
+ if (event.data.type === "instant_record.clear_log") {
705
+ history.length = 0;
706
+ return;
707
+ }
708
+
709
+ // Inspector controls: report toggles the event stream, offline and latencyMs
710
+ // simulate a bad network on the sync server only.
711
+ if (event.data.type === "instant_record.simulate") {
712
+ const wasOffline = sim.offline;
713
+ const before = `${sim.offline}/${sim.latencyMs}`;
714
+ Object.assign(sim, event.data.settings || {});
715
+
716
+ // Cut a live stream the moment the network is "lost".
717
+ if (sim.offline && !wasOffline) streamAbort?.abort();
718
+
719
+ // Only worth a line when something actually changed: every page load
720
+ // re-states its settings, and logging that is pure noise.
721
+ if (`${sim.offline}/${sim.latencyMs}` !== before) {
722
+ report({
723
+ kind: "sim",
724
+ path: `offline: ${sim.offline}, latency: ${sim.latencyMs}ms`,
725
+ streamOpen: streaming,
726
+ });
727
+ }
728
+
729
+ // Only when asked. A page requests this once, on load: replaying it later
730
+ // would replace a log that has since accumulated entries of its own.
731
+ if (event.data.backlog) {
732
+ event.source?.postMessage({
733
+ type: "instant_record.debug_backlog",
734
+ entries: history,
735
+ streamOpen: streaming,
736
+ });
737
+ }
738
+ return;
739
+ }
740
+
741
+ if (event.data.type === "reload-rails") {
742
+ const progress = new Progress();
743
+ await progress.attach(self);
744
+
745
+ progress.updateStep("Reloading Rails application...");
746
+
747
+ resetVM();
748
+ await initVM(progress, { debug: event.data.debug });
749
+ }
750
+ });