@inkandswitch/patchwork-bootloader 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @inkandswitch/patchwork-bootloader
2
2
 
3
+ ## 0.3.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 0e1eb95: add syncstate info shape
8
+
3
9
  ## 0.3.1
4
10
 
5
11
  ### Patch Changes
@@ -24,7 +24,7 @@ import { IndexedDBWorkerStorageAdapter } from "@automerge/automerge-repo-storage
24
24
  import { MessageChannelNetworkAdapter } from "@automerge/automerge-repo-network-messagechannel";
25
25
  import { WebSocketWorkerClientAdapter } from "@automerge/automerge-repo-network-websocket";
26
26
  import { initializeAutomergeRepoKeyhiveRustWithRepo, initKeyhiveWasm, } from "@automerge/automerge-repo-keyhive";
27
- import { HANDOFF_CHANNEL, } from "./types.js";
27
+ import { HANDOFF_CHANNEL, SYNCSTATE_CHANNEL, } from "./types.js";
28
28
  let debugging = false;
29
29
  // Per-boot identity so a tab can detect a worker *restart*: a fresh instance
30
30
  // means a new repo peerId + cold in-memory state, so the tab's docs must be
@@ -36,6 +36,42 @@ const WORKER_BOOT_TIME = Date.now();
36
36
  // → shared workers). Patch console.* and the global error handlers to also
37
37
  // post back over every connected tab's control port, tagged [automerge-worker].
38
38
  const controlPorts = new Set();
39
+ // ── Per-tab sync-state subscriptions ────────────────────────────────────
40
+ // Each tab's control port subscribes to the documents it cares about; we push
41
+ // only those docs' heads back down that port (addressed — tab A never sees tab
42
+ // B's docs), and drop a port's whole subscription set when it closes (the tab
43
+ // went away), so there's nothing to reference-count or time out. The global
44
+ // connection/whoami signals still go over SYNCSTATE_CHANNEL.
45
+ const syncWatchers = new Map();
46
+ // Installed by setupSyncStateBroadcast once the repo's snapshot exists, so a
47
+ // fresh `sync-sub` can be replayed the doc's current heads immediately. Null
48
+ // until then; subscriptions taken during boot are replayed when it installs.
49
+ let replaySyncForPort = null;
50
+ function syncSubscribe(port, documentId) {
51
+ let docs = syncWatchers.get(port);
52
+ if (!docs)
53
+ syncWatchers.set(port, (docs = new Set()));
54
+ if (docs.has(documentId))
55
+ return;
56
+ docs.add(documentId);
57
+ replaySyncForPort?.(documentId, port);
58
+ }
59
+ function syncUnsubscribe(port, documentId) {
60
+ syncWatchers.get(port)?.delete(documentId);
61
+ }
62
+ // Push one document's heads to every control port currently watching it.
63
+ function pushSyncState(message) {
64
+ for (const [port, docs] of syncWatchers) {
65
+ if (!docs.has(message.documentId))
66
+ continue;
67
+ try {
68
+ port.postMessage(message);
69
+ }
70
+ catch {
71
+ // Port already gone; its close handler will reap the entry.
72
+ }
73
+ }
74
+ }
39
75
  // Logs emitted before any tab has connected (e.g. during wasm boot) would
40
76
  // otherwise be lost — buffer a bounded number and flush on first connect.
41
77
  const preConnectBuffer = [];
@@ -130,6 +166,13 @@ const SUBDUCTION_ENDPOINTS = [
130
166
  : "wss://subduction.sync.inkandswitch.com",
131
167
  ];
132
168
  const RESOLVE_TIMEOUT_MS = 30_000;
169
+ // Backoff re-sync of stuck/diverged docs. Only this worker is connected to the
170
+ // sync server, so it's the only place that can notice a doc whose heads have
171
+ // settled out of sync with the server and re-arm a sync round for it.
172
+ const RESYNC_GRACE_MS = 8_000; // must be *stably* diverged this long first
173
+ const RESYNC_INITIAL_DELAY_MS = 5_000; // first backoff cooldown after a resync
174
+ const RESYNC_MAX_DELAY_MS = 60_000; // backoff cap
175
+ const RESYNC_REVIEW_INTERVAL_MS = 5_000; // how often stuck docs are re-checked
133
176
  const DEFAULT_CLASSIC_SYNC_SERVER = "wss://sync3.automerge.org";
134
177
  let classicSyncServer = DEFAULT_CLASSIC_SYNC_SERVER;
135
178
  let classicSyncAdapter = null;
@@ -185,6 +228,11 @@ function getRepoHive() {
185
228
  log("wasm initialized");
186
229
  if (!useKeyhive) {
187
230
  const signer = await WebCryptoSigner.setup();
231
+ const identity = {
232
+ peerId: signer.peerId().toString(),
233
+ verifyingKey: signer.verifyingKey().toHex(),
234
+ };
235
+ console.log("[patchwork] shared-worker subduction identity:", identity);
188
236
  const repo = new Repo({
189
237
  storage: new IndexedDBWorkerStorageAdapter(),
190
238
  signer,
@@ -199,6 +247,8 @@ function getRepoHive() {
199
247
  subductionWebsocketEndpoints: SUBDUCTION_ENDPOINTS,
200
248
  });
201
249
  self.repo = repo;
250
+ self.syncIdentity = identity;
251
+ setupSyncStateBroadcast(repo, identity);
202
252
  log("repo constructed (no keyhive), waiting for network subsystem");
203
253
  repo.networkSubsystem.whenReady().then(() => {
204
254
  log("repo network subsystem ready");
@@ -225,6 +275,7 @@ function getRepoHive() {
225
275
  });
226
276
  self.repo = repo;
227
277
  self.hive = hive;
278
+ setupSyncStateBroadcast(repo);
228
279
  log("repo constructed, waiting for network subsystem");
229
280
  // Don't block getRepoHive() on whenReady() — the network subsystem starts
230
281
  // with only the subduction adapter, and the MessageChannel adapter is
@@ -247,6 +298,285 @@ function getRepoHive() {
247
298
  }
248
299
  return repoHivePromise;
249
300
  }
301
+ // ── Sync-state broadcast ───────────────────────────────────────────────
302
+ //
303
+ // Only this worker is directly connected to the sync server, so it's the only
304
+ // place that learns the server's heads (the repo's "subduction-remote-heads"
305
+ // event, keyed by each Subduction peer's verifying-key storageId) and whether
306
+ // the server link is up ("subduction-connection"). We rebroadcast both on
307
+ // SYNCSTATE_CHANNEL so every tab can render a sync indicator without holding
308
+ // its own server connection. A tab that opens mid-stream posts {type:"request"}
309
+ // to get the current snapshot replayed.
310
+ let syncStateWired = false;
311
+ function setupSyncStateBroadcast(repo, identity) {
312
+ if (syncStateWired)
313
+ return;
314
+ syncStateWired = true;
315
+ const channel = new BroadcastChannel(SYNCSTATE_CHANNEL);
316
+ // documentId -> storageId (verifying key) -> last-known heads
317
+ const snapshot = new Map();
318
+ let connected = repo.isSubductionConnected();
319
+ // Directly-connected sync-server peer ids (verifying keys). Stable once
320
+ // known; tabs use this to judge "synced" against the server specifically.
321
+ let serverPeerIds = [];
322
+ const postWhoAmI = () => {
323
+ if (!identity)
324
+ return;
325
+ channel.postMessage({
326
+ type: "whoami",
327
+ peerId: identity.peerId,
328
+ verifyingKey: identity.verifyingKey,
329
+ });
330
+ };
331
+ // Announce our identity so tabs can label which peer rows are this worker.
332
+ postWhoAmI();
333
+ // Heads are addressed, not broadcast: push a doc's heads only to the control
334
+ // ports that subscribed to it (see syncWatchers / pushSyncState).
335
+ const postHeads = (documentId, storageId, heads, timestamp) => pushSyncState({
336
+ type: "sync-state",
337
+ documentId,
338
+ storageId,
339
+ heads,
340
+ timestamp,
341
+ });
342
+ // Let a `sync-sub` (which may have arrived while the repo was still booting)
343
+ // replay this doc's current snapshot to the subscribing port immediately.
344
+ const replayDoc = (documentId, port) => {
345
+ const byStorage = snapshot.get(documentId);
346
+ if (!byStorage)
347
+ return;
348
+ for (const [storageId, { heads, timestamp }] of byStorage) {
349
+ try {
350
+ port.postMessage({
351
+ type: "sync-state",
352
+ documentId,
353
+ storageId,
354
+ heads,
355
+ timestamp,
356
+ });
357
+ }
358
+ catch {
359
+ // Port gone; its close handler reaps it.
360
+ }
361
+ }
362
+ };
363
+ replaySyncForPort = replayDoc;
364
+ // Catch up any ports that subscribed before this wiring existed.
365
+ for (const [port, docs] of syncWatchers) {
366
+ for (const documentId of docs)
367
+ replayDoc(documentId, port);
368
+ }
369
+ const postConnection = () => channel.postMessage({
370
+ type: "connection",
371
+ connected,
372
+ serverPeerIds,
373
+ });
374
+ // Learn (and re-announce) which connected Subduction peer is the sync server.
375
+ // The peer list is empty until the handshake finishes, so retry briefly.
376
+ const refreshServerPeers = async () => {
377
+ for (let attempt = 0; attempt < 6; attempt++) {
378
+ try {
379
+ const ids = await repo.connectedSubductionPeerIds();
380
+ if (ids.length > 0) {
381
+ serverPeerIds = ids;
382
+ postConnection();
383
+ return;
384
+ }
385
+ }
386
+ catch {
387
+ // repo has no subduction source / not ready yet
388
+ }
389
+ await new Promise((r) => setTimeout(r, 500));
390
+ }
391
+ };
392
+ // Advertise the worker's OWN heads for every doc it holds (keyed by our
393
+ // verifying key), so the worker hop is visible on every document.
394
+ //
395
+ // Docs pushed in by Subduction that this worker never explicitly opened don't
396
+ // surface via the repo's "document" event, so we discover them by re-scanning
397
+ // repo.handles (on a tick, and whenever the server reports a doc) and attach a
398
+ // heads-changed listener once per doc. No-op when there's no identity (keyhive
399
+ // path).
400
+ const ownTracked = new Set();
401
+ const broadcastOwnHeads = (handle) => {
402
+ if (!identity)
403
+ return;
404
+ const documentId = handle.documentId;
405
+ let heads;
406
+ try {
407
+ heads = [...handle.heads()];
408
+ }
409
+ catch {
410
+ return; // handle not ready yet
411
+ }
412
+ const timestamp = Date.now();
413
+ let byStorage = snapshot.get(documentId);
414
+ if (!byStorage) {
415
+ byStorage = new Map();
416
+ snapshot.set(documentId, byStorage);
417
+ }
418
+ byStorage.set(identity.peerId, { heads, timestamp });
419
+ postHeads(documentId, identity.peerId, heads, timestamp);
420
+ reviewResync(documentId);
421
+ };
422
+ const trackOwnHandle = (handle) => {
423
+ if (!identity || ownTracked.has(handle.documentId))
424
+ return;
425
+ ownTracked.add(handle.documentId);
426
+ handle.on("heads-changed", () => broadcastOwnHeads(handle));
427
+ broadcastOwnHeads(handle);
428
+ };
429
+ const scanOwnHandles = () => {
430
+ if (!identity)
431
+ return;
432
+ for (const handle of Object.values(repo.handles)) {
433
+ trackOwnHandle(handle);
434
+ }
435
+ };
436
+ // ── Backoff re-sync of stuck/diverged docs ──────────────────────────
437
+ //
438
+ // Subduction sync is event-driven and only retries syncs it observed *fail*;
439
+ // a doc that settles missing commits the server holds — or whose heal retries
440
+ // were exhausted — is otherwise never retried. When we're behind and the
441
+ // server's advertised heads haven't advanced for a grace window (so it's
442
+ // genuinely stuck, not just lagging a live edit), we re-arm its sync round
443
+ // with per-doc exponential backoff. Convergence clears the state.
444
+ const serverHeadSetsFor = (documentId) => {
445
+ const byStorage = snapshot.get(documentId);
446
+ if (!byStorage)
447
+ return [];
448
+ const sets = [];
449
+ for (const [storageId, { heads }] of byStorage) {
450
+ if (serverPeerIds.includes(storageId))
451
+ sets.push(heads);
452
+ }
453
+ return sets;
454
+ };
455
+ const resyncState = new Map();
456
+ // Inspectable from the SharedWorker console as `self.patchworkResync` to see
457
+ // whether/how often a doc is being re-synced and against which server heads.
458
+ const resyncDiag = (self.patchworkResync ??= { fires: 0, byDoc: {} });
459
+ const reviewResync = (documentId) => {
460
+ if (!identity || !connected) {
461
+ resyncState.delete(documentId);
462
+ return;
463
+ }
464
+ const handle = repo.handles[documentId];
465
+ if (!handle)
466
+ return;
467
+ const serverSets = serverHeadSetsFor(documentId);
468
+ if (serverSets.length === 0) {
469
+ resyncState.delete(documentId); // no server signal to compare against
470
+ return;
471
+ }
472
+ // The server advertises subduction *sedimentree* heads (loose-commit +
473
+ // fragment-boundary commit ids), which are NOT the Automerge frontier — so
474
+ // never compare them to handle.heads() for equality. Instead ask whether we
475
+ // already hold every commit the server advertises (`DocHandle.containsHeads`).
476
+ // If we do, the server has nothing we're missing → caught up. If not, we're
477
+ // genuinely behind and a re-sync can pull the rest.
478
+ const serverHeadsUrl = [...new Set(serverSets.flat())];
479
+ let haveAll;
480
+ try {
481
+ haveAll = handle.containsHeads(serverHeadsUrl);
482
+ }
483
+ catch {
484
+ return; // doc not ready, or an undecodable head
485
+ }
486
+ if (haveAll) {
487
+ resyncState.delete(documentId); // we hold everything the server has
488
+ return;
489
+ }
490
+ // Behind. "Stuck" = the server's advertised set hasn't advanced (no
491
+ // progress) for a while. Key the grace timer on the server heads only, so
492
+ // your own edits churning don't keep resetting it.
493
+ const serverSig = [...serverHeadsUrl].sort().join(",");
494
+ const now = Date.now();
495
+ const prev = resyncState.get(documentId);
496
+ if (!prev || prev.serverSig !== serverSig) {
497
+ // First sighting, or the server advanced its view (progress): restart.
498
+ resyncState.set(documentId, {
499
+ serverSig,
500
+ since: now,
501
+ delay: RESYNC_INITIAL_DELAY_MS,
502
+ lastResyncAt: 0,
503
+ });
504
+ return;
505
+ }
506
+ if (now - prev.since < RESYNC_GRACE_MS)
507
+ return; // not stuck long enough yet
508
+ if (now - prev.lastResyncAt < prev.delay)
509
+ return; // within backoff cooldown
510
+ log("re-syncing behind doc", documentId, { serverSets });
511
+ resyncDiag.fires++;
512
+ resyncDiag.byDoc[documentId] = {
513
+ at: now,
514
+ count: (resyncDiag.byDoc[documentId]
515
+ ?.count ?? 0) + 1,
516
+ serverSets,
517
+ };
518
+ try {
519
+ repo.resyncSubduction(documentId);
520
+ }
521
+ catch (e) {
522
+ log("resyncSubduction failed", e);
523
+ }
524
+ prev.lastResyncAt = now;
525
+ prev.delay = Math.min(prev.delay * 2, RESYNC_MAX_DELAY_MS);
526
+ };
527
+ const reviewAllResync = () => {
528
+ if (!identity)
529
+ return;
530
+ for (const documentId of snapshot.keys())
531
+ reviewResync(documentId);
532
+ for (const id of [...resyncState.keys()]) {
533
+ if (!snapshot.has(id))
534
+ resyncState.delete(id);
535
+ }
536
+ };
537
+ repo.on("subduction-remote-heads", ({ documentId, storageId, heads, timestamp }) => {
538
+ const headsCopy = [...heads];
539
+ let byStorage = snapshot.get(documentId);
540
+ if (!byStorage) {
541
+ byStorage = new Map();
542
+ snapshot.set(documentId, byStorage);
543
+ }
544
+ byStorage.set(storageId, { heads: headsCopy, timestamp });
545
+ postHeads(documentId, storageId, headsCopy, timestamp);
546
+ // A doc the server reported is one we hold — make sure we're advertising
547
+ // our own heads for it too.
548
+ scanOwnHandles();
549
+ reviewResync(documentId);
550
+ });
551
+ repo.on("subduction-connection", ({ connected: isConnected }) => {
552
+ connected = isConnected;
553
+ postConnection();
554
+ if (isConnected)
555
+ void refreshServerPeers();
556
+ });
557
+ // A BroadcastChannel never receives its own posts, so this only sees tabs'
558
+ // requests, never our own broadcasts. We replay just the global signals here;
559
+ // a late tab gets per-doc heads by subscribing (sync-sub), not from this.
560
+ channel.addEventListener("message", (event) => {
561
+ const data = event.data;
562
+ if (data?.type !== "request")
563
+ return;
564
+ postWhoAmI();
565
+ postConnection();
566
+ });
567
+ // In case we're already connected by the time this wires up.
568
+ void refreshServerPeers();
569
+ // Discover the worker's docs by re-scanning repo.handles initially and on a
570
+ // tick (Subduction-pushed docs don't surface via the "document" event).
571
+ scanOwnHandles();
572
+ if (identity)
573
+ setInterval(scanOwnHandles, 3000);
574
+ // Drive the backoff re-sync of stuck/diverged docs. A tick is essential here:
575
+ // the "stuck" case is precisely when no head events are firing, so the
576
+ // grace/backoff timers can only advance on a timer.
577
+ if (identity)
578
+ setInterval(reviewAllResync, RESYNC_REVIEW_INTERVAL_MS);
579
+ }
250
580
  function dropRepoChannel(repo, channel) {
251
581
  // removeNetworkAdapter pulls the adapter out of networkSubsystem.adapters and
252
582
  // calls adapter.disconnect(), which (for the MessageChannel adapter) emits the
@@ -329,6 +659,16 @@ function handleControlMessage(event, controlPort, connection) {
329
659
  });
330
660
  });
331
661
  }
662
+ else if (data?.type === "sync-sub") {
663
+ if (typeof data.documentId === "string") {
664
+ syncSubscribe(controlPort, data.documentId);
665
+ }
666
+ }
667
+ else if (data?.type === "sync-unsub") {
668
+ if (typeof data.documentId === "string") {
669
+ syncUnsubscribe(controlPort, data.documentId);
670
+ }
671
+ }
332
672
  else if (data?.type === "debug") {
333
673
  debugging = data.debug;
334
674
  log("automerge worker debugging enabled");
@@ -372,6 +712,9 @@ self.addEventListener("connect", (event) => {
372
712
  // event fall back to the adapters' lazy useWeakRef cleanup.
373
713
  controlPort.addEventListener("close", () => {
374
714
  controlPorts.delete(controlPort);
715
+ // The tab is gone — drop its sync subscriptions wholesale so we stop
716
+ // pushing it heads (no per-doc unsub needed, no leak).
717
+ syncWatchers.delete(controlPort);
375
718
  void dropConnection(connection);
376
719
  });
377
720
  controlPort.start();
package/dist/setup.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import type { SetupServiceWorkerOptions, SetupServiceWorkerResult } from "./types.js";
1
+ import type { SetupServiceWorkerOptions, SetupServiceWorkerResult, SyncStateDocMessage } from "./types.js";
2
2
  export declare function lifecycleLoggingEnabled(): boolean;
3
3
  export declare function bumpServiceWorkerCache(sw?: ServiceWorker | null): void;
4
4
  export declare function getAutomergeWorker(): SharedWorker;
5
+ type SyncStateListener = (update: SyncStateDocMessage) => void;
6
+ export declare function subscribeSyncState(documentId: string, listener: SyncStateListener): () => void;
5
7
  export declare function connectClassicSync(server?: string): Promise<void>;
6
8
  export default function setupServiceWorker(options?: SetupServiceWorkerOptions): Promise<SetupServiceWorkerResult>;
9
+ export {};
package/dist/setup.js CHANGED
@@ -91,6 +91,10 @@ export function getAutomergeWorker() {
91
91
  // Surface the SharedWorker's console output and uncaught errors in this
92
92
  // tab's console (it has its own console that's awkward to find otherwise).
93
93
  automergeWorker.port.addEventListener("message", (event) => {
94
+ if (event.data?.type === "sync-state") {
95
+ dispatchSyncState(event.data);
96
+ return;
97
+ }
94
98
  if (event.data?.type !== "console")
95
99
  return;
96
100
  const { level, args } = event.data;
@@ -181,6 +185,44 @@ function installWorkerDeathDetection(worker) {
181
185
  }
182
186
  }, HEARTBEAT_MS);
183
187
  }
188
+ const syncStateListeners = new Map();
189
+ function dispatchSyncState(update) {
190
+ const listeners = syncStateListeners.get(update.documentId);
191
+ if (!listeners)
192
+ return;
193
+ for (const listener of listeners) {
194
+ try {
195
+ listener(update);
196
+ }
197
+ catch (err) {
198
+ console.error("sync-state listener threw", err);
199
+ }
200
+ }
201
+ }
202
+ export function subscribeSyncState(documentId, listener) {
203
+ const worker = getAutomergeWorker();
204
+ let listeners = syncStateListeners.get(documentId);
205
+ if (!listeners) {
206
+ syncStateListeners.set(documentId, (listeners = new Set()));
207
+ // First local watcher for this doc — ask the worker to start pushing it.
208
+ worker.port.postMessage({ type: "sync-sub", documentId });
209
+ }
210
+ listeners.add(listener);
211
+ let active = true;
212
+ return () => {
213
+ if (!active)
214
+ return; // idempotent
215
+ active = false;
216
+ const set = syncStateListeners.get(documentId);
217
+ if (!set)
218
+ return;
219
+ set.delete(listener);
220
+ if (set.size === 0) {
221
+ syncStateListeners.delete(documentId);
222
+ worker.port.postMessage({ type: "sync-unsub", documentId });
223
+ }
224
+ };
225
+ }
184
226
  export function connectClassicSync(server = readClassicSyncServer()) {
185
227
  const url = server.trim() || DEFAULT_CLASSIC_SYNC_SERVER;
186
228
  if (!/^wss?:\/\//.test(url)) {
@@ -318,6 +360,7 @@ export default async function setupServiceWorker(options) {
318
360
  shared,
319
361
  connectClassicSync,
320
362
  getRepoChannel,
363
+ subscribeSyncState,
321
364
  async subscribeToRepoChannel(listener) {
322
365
  // The automerge worker outlives the page, so unlike the old in-service-
323
366
  // worker repo there's nothing to reconnect: one port, handed over once.
package/dist/site.d.ts CHANGED
@@ -16,7 +16,7 @@ import { type AutomergeRepoKeyhive } from "@automerge/automerge-repo-keyhive";
16
16
  import { ModuleWatcher } from "@inkandswitch/patchwork-filesystem";
17
17
  import { type AccountDoc } from "@inkandswitch/patchwork-plugins";
18
18
  import * as plugins from "@inkandswitch/patchwork-plugins";
19
- import type { ServiceWorkerRepoChannelListener } from "./types.js";
19
+ import type { ServiceWorkerRepoChannelListener, SyncStateDocMessage } from "./types.js";
20
20
  declare global {
21
21
  interface Window {
22
22
  accountDocHandle: DocHandle<AccountDoc>;
@@ -30,9 +30,14 @@ declare global {
30
30
  packages: ModuleWatcher;
31
31
  plugins: typeof plugins;
32
32
  accountDocHandle: DocHandle<AccountDoc>;
33
+ signer?: {
34
+ peerId: string;
35
+ verifyingKey: string;
36
+ };
33
37
  sw: {
34
38
  connectClassicSync: (server?: string) => Promise<void>;
35
39
  subscribeToRepoChannel: (listener: ServiceWorkerRepoChannelListener) => Promise<() => void>;
40
+ subscribeSyncState: (documentId: string, listener: (update: SyncStateDocMessage) => void) => () => void;
36
41
  };
37
42
  };
38
43
  uncache: (match: string) => Promise<void>;