@evolu/common 8.15.1 → 8.16.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.
@@ -102,18 +102,19 @@
102
102
  * another tenant's request is still a valid reconciliation step.
103
103
  *
104
104
  * Traffic goes only where it is needed. A continuation returns to the transport
105
- * that produced the response, and a round started by a socket opening or by a
106
- * tenant's first use of a transport for an owner goes through that transport.
107
- * Explicit synchronization requests and mutation uploads go to every open
108
- * transport claimed for the owner. A write uploads through the database's
109
- * writable registrations for its owner, whichever instance made it, even one
110
- * disposed before the database worker answered. When a relay frame stores new
111
- * messages, the tenant requests a round through each other transport claimed
112
- * for the owner, so data learned from one relay reaches the others, except
113
- * through a route that skipped a message, as described below. A closed
114
- * transport reconciles when it opens, and a replacement leader reconciles every
115
- * transport again, because a response reporting stored messages may have been
116
- * lost.
105
+ * that produced the response, a round started by a socket opening or by a
106
+ * transport's first claim for an owner goes through that transport, and a
107
+ * tenant's first writable registration of an owner starts one through each
108
+ * transport already claimed for it. Explicit synchronization requests and
109
+ * mutation uploads go to every open transport claimed for the owner. A write
110
+ * uploads through the database's writable registrations for its owner,
111
+ * whichever instance made it, even one disposed before the database worker
112
+ * answered. When a relay frame stores new messages, the tenant requests a round
113
+ * through each other transport claimed for the owner, so data learned from one
114
+ * relay reaches the others, except through a route that skipped a message, as
115
+ * described below. A closed transport reconciles when it opens, and a
116
+ * replacement leader reconciles every transport again, because a response
117
+ * reporting stored messages may have been lost.
117
118
  *
118
119
  * Relays omit the sending socket when broadcasting an upload, so the uploader
119
120
  * also delivers it as local Broadcast frames to every other tenant with
@@ -128,12 +129,13 @@
128
129
  * ## Sync state
129
130
  *
130
131
  * The shared worker publishes one plain snapshot, {@link SyncState}, of every
131
- * transport it manages and every database and owner registration it holds, with
132
- * one route per writable registration and transport, as specified below. Each
133
- * part is a union of the states the worker keeps for it: a transport's
134
- * connection, a database that is active or refused startup, a writable or
135
- * readonly owner, and a pending, settled, or complete route. A transport's
136
- * events drive its connection, so publishing never reads a socket.
132
+ * transport it manages and every database it holds, with the owners its
133
+ * instances registered and, for each writable owner, one route per transport
134
+ * claimed for the owner by any database, as specified below. Each part is a
135
+ * union of the states the worker keeps for it: a transport's connection, a
136
+ * database that is active or refused startup, a writable or readonly owner, and
137
+ * a pending, settled, or complete route. A transport's events drive its
138
+ * connection, so publishing never reads a socket.
137
139
  *
138
140
  * Apps show users an owner's {@link OwnerSyncStatus}, which
139
141
  * {@link syncStateToOwnerSyncStatus} derives. A view of each relay pairs each
@@ -172,10 +174,10 @@
172
174
  * - The tenant has no replicated write for the owner queued, because its upload
173
175
  * is sent only after the database worker answers it.
174
176
  * - The tenant has sent a round through the transport since the last event that
175
- * requires one: its first use of the transport for the owner, the socket
176
- * opening, an explicit request, a replacement leader, or storing messages
177
- * from another transport, unless the route skipped a message. No failed or
178
- * aborted result has arrived on the route since.
177
+ * requires one: the route's creation, the socket opening, an explicit
178
+ * request, a replacement leader, or storing messages from another transport,
179
+ * unless the route skipped a message. No failed or aborted result has arrived
180
+ * on the route since.
179
181
  * - No result that skipped a received message has arrived on the route since a
180
182
  * round was last requested through it, and since that request, no messages
181
183
  * have been stored from another transport, directly or through a sibling copy
@@ -197,7 +199,8 @@
197
199
  * requires a round through every transport whose route has not skipped one.
198
200
  *
199
201
  * A failed result on a route requests one round through it. Any further failure
200
- * before the route settles waits for an explicit request or a reopen, so a
202
+ * before the route settles requests no round of its own and waits for the next
203
+ * round another event requests, such as an explicit request or a reopen, so a
201
204
  * persistent failure cannot loop; a converged reply in between does not end the
202
205
  * wait, because it may answer another tenant's round on the shared socket. An
203
206
  * aborted apply leaves its routes incomplete without a retry. An exception
@@ -216,9 +219,8 @@
216
219
  * message with that timestamp. The messages received elsewhere that the last
217
220
  * condition names request no round through such a route, even while a requested
218
221
  * round checks it again, because each round would download every skipped
219
- * message again. The next round that a first use of the transport for the
220
- * owner, an explicit request, a reopen, a replacement leader, or a failure
221
- * sends through the route reconciles them.
222
+ * message again. The next round that an explicit request, a reopen, a
223
+ * replacement leader, or a failure sends through the route reconciles them.
222
224
  *
223
225
  * ### Liveness
224
226
  *
@@ -292,17 +294,12 @@ import {
292
294
  type ClaimLease,
293
295
  type SharedResourceByKeyWithClaims,
294
296
  } from "../Resource.ts";
295
- import { ok, type Result } from "../Result.ts";
297
+ import { ok, trySync, type Result } from "../Result.ts";
296
298
  import type { NonEmptyReadonlySet } from "../Set.ts";
297
299
  import type { SqliteSchema } from "../Sqlite.ts";
298
300
  import { createStore, type Store } from "../Store.ts";
299
- import {
300
- AbortError,
301
- createMutex,
302
- unabortable,
303
- type Mutex,
304
- type Task,
305
- } from "../Task.ts";
301
+ import { safelyStringifyUnknownValue } from "../String.ts";
302
+ import { AbortError, createMutex, unabortable, type Task } from "../Task.ts";
306
303
  import {
307
304
  performanceDurationBetween,
308
305
  PositiveMillis,
@@ -331,9 +328,11 @@ import type {
331
328
  WebSocketError,
332
329
  } from "../WebSocket.ts";
333
330
  import type {
331
+ BroadcastChannel,
334
332
  SharedWorker as CommonSharedWorker,
335
333
  CreateBroadcastChannelDep,
336
334
  CreateMessageChannelDep,
335
+ CreateMessagePortDep,
337
336
  MessagePort,
338
337
  NativeMessagePort,
339
338
  SharedWorkerSelf,
@@ -403,6 +402,8 @@ export type SharedWorkerInput =
403
402
  readonly evoluPort: NativeMessagePort<EvoluOutput, EvoluInput>;
404
403
  };
405
404
 
405
+ export type EvoluInstanceId = Id & Brand<"EvoluInstance">;
406
+
406
407
  export type SharedWorkerOutput =
407
408
  | DbWorkerInit
408
409
  | {
@@ -447,6 +448,39 @@ export type ConsoleEntryOrError =
447
448
  export const consoleEntryOrErrorBroadcastChannelName =
448
449
  "evolu:console-entry-or-error";
449
450
 
451
+ /**
452
+ * Posts a {@link ConsoleEntry} to tabs on a channel named
453
+ * {@link consoleEntryOrErrorBroadcastChannelName}.
454
+ *
455
+ * An argument that cannot be structured-cloned is replaced: an `Error` by the
456
+ * plain object {@link createUnknownError} makes of it, and any other value, such
457
+ * as an object holding a function, by a string. Logging such a value in a
458
+ * worker therefore does not throw.
459
+ */
460
+ export const postConsoleEntry = (
461
+ channel: BroadcastChannel<ConsoleEntryOrError>,
462
+ entry: ConsoleEntry,
463
+ ): void => {
464
+ // A native BroadcastChannel throws a DataCloneError synchronously, into
465
+ // whatever called the console.
466
+ const posted = trySync(() =>
467
+ channel.postMessage({ type: "ConsoleEntry", entry }),
468
+ );
469
+ if (posted.ok) return;
470
+ channel.postMessage({
471
+ type: "ConsoleEntry",
472
+ entry: {
473
+ ...entry,
474
+ args: entry.args.map((arg) => {
475
+ if (trySync(() => structuredClone(arg)).ok) return arg;
476
+ return arg instanceof Error
477
+ ? createUnknownError(arg).error
478
+ : safelyStringifyUnknownValue(arg);
479
+ }),
480
+ },
481
+ });
482
+ };
483
+
450
484
  /** Identifies one running SharedWorker instance. */
451
485
  export const SharedWorkerId = /*#__PURE__*/ id("SharedWorker");
452
486
  export type SharedWorkerId = typeof SharedWorkerId.Output;
@@ -639,8 +673,10 @@ export type SyncRoute = PendingSyncRoute | SettledSyncRoute | CompleteSyncRoute;
639
673
  export interface PendingSyncRoute extends Typed<"Pending"> {
640
674
  readonly transportId: SyncTransportId;
641
675
  /**
642
- * The failure since the route last settled, or null. The first one requests a
643
- * round; a further one waits for {@link Evolu.requestSync} or a reopen.
676
+ * The failure since the route last settled, or null. The first failure in a
677
+ * reply requests one round through the route; a later one, or a failure to
678
+ * create a round, waits for the next round another event requests, such as
679
+ * {@link Evolu.requestSync} or a reopen.
644
680
  */
645
681
  readonly failure: SyncRouteError | null;
646
682
  /**
@@ -674,8 +710,11 @@ export interface SettledSyncRoute extends Typed<"Settled"> {
674
710
  readonly completeAt: Millis | null;
675
711
  /** When this database last sent a request through the route. */
676
712
  readonly lastSentAt: Millis;
677
- /** When processing a frame from the route last finished, or null. */
678
- readonly lastReceivedAt: Millis | null;
713
+ /**
714
+ * When processing a frame from the route last finished. Never null, because
715
+ * the reply that skipped the change set it.
716
+ */
717
+ readonly lastReceivedAt: Millis;
679
718
  }
680
719
 
681
720
  /** A route on which the database is reconciled with the relay for the owner. */
@@ -697,23 +736,23 @@ export interface CompleteSyncRoute extends Typed<"Complete"> {
697
736
  * actual timestamps of a {@link ProtocolTimestampMismatchError}. A skipped
698
737
  * message adds {@link DecryptWithXChaCha20Poly1305Error}. A
699
738
  * {@link ProtocolInvalidDataError} leaves out its data, which can be a whole
700
- * frame. The caught value in the `error` of either is an {@link UnknownError}.
701
- * `WriteFailed` means a `writeMessages` call that threw, logged by the
739
+ * frame. `WriteFailed` means a `writeMessages` call that threw, logged by the
702
740
  * protocol, and `SyncFailed` means a logged failure while creating a round or
703
741
  * reconciling ranges.
704
742
  */
705
743
  export type SyncRouteError = (
706
744
  | Exclude<ProtocolError, ProtocolInvalidDataError>
707
- | Omit<ProtocolInvalidDataError, "data">
745
+ | (Omit<ProtocolInvalidDataError, "data" | "error"> & {
746
+ readonly error: UnknownError;
747
+ })
708
748
  | StorageWriteMessagesError
709
- | DecryptWithXChaCha20Poly1305Error
749
+ | (Omit<DecryptWithXChaCha20Poly1305Error, "error"> & {
750
+ readonly error: UnknownError;
751
+ })
710
752
  | Typed<"WriteFailed">
711
753
  | Typed<"SyncFailed">
712
754
  ) & { readonly at: Millis };
713
755
 
714
- /** The type of a {@link SyncRouteError}. */
715
- export type SyncRouteErrorType = SyncRouteError["type"];
716
-
717
756
  /**
718
757
  * One relay of an owner in one database: a transport and the database's route
719
758
  * through it, from {@link syncStateToRelaySyncStates}. Its status comes from
@@ -1267,13 +1306,11 @@ export type DbWorkerReadRequest =
1267
1306
  export type DbWorkerOutput =
1268
1307
  | {
1269
1308
  readonly type: "LeaderAcquired";
1270
- readonly name: Name;
1271
1309
  readonly clock: Timestamp;
1272
1310
  }
1273
1311
  | {
1274
1312
  /** Startup was refused; the worker is releasing its resources. */
1275
1313
  readonly type: "LeaderRefused";
1276
- readonly name: Name;
1277
1314
  readonly error: UnsupportedDbVersionError;
1278
1315
  }
1279
1316
  | {
@@ -1372,7 +1409,7 @@ interface EvoluTenant extends AsyncDisposable {
1372
1409
  onDisposed: () => void,
1373
1410
  ) => void;
1374
1411
 
1375
- readonly getSyncTenant: () => TenantSyncState;
1412
+ readonly getSyncTenant: () => SyncTenant;
1376
1413
 
1377
1414
  readonly refreshSyncRoutes: () => void;
1378
1415
 
@@ -1388,34 +1425,6 @@ interface EvoluTenant extends AsyncDisposable {
1388
1425
  ) => void;
1389
1426
  }
1390
1427
 
1391
- /** A tenant's part of {@link SyncState}, with its transports still keyed. */
1392
- type TenantSyncState = ActiveTenantSyncState | RefusedSyncTenant;
1393
-
1394
- interface ActiveTenantSyncState extends Typed<"Active"> {
1395
- readonly name: Name;
1396
- readonly owners: ReadonlyArray<TenantSyncOwner>;
1397
- }
1398
-
1399
- type TenantSyncOwner = WritableTenantSyncOwner | ReadonlyTenantSyncOwner;
1400
-
1401
- interface WritableTenantSyncOwner extends Typed<"Writable"> {
1402
- readonly ownerId: OwnerId;
1403
- readonly routes: ReadonlyArray<TenantSyncRoute>;
1404
- }
1405
-
1406
- interface ReadonlyTenantSyncOwner extends Typed<"Readonly"> {
1407
- readonly ownerId: OwnerId;
1408
- readonly transportKeys: ReadonlyArray<StructuralLookupKey>;
1409
- }
1410
-
1411
- /** A tenant's route, with its transport still keyed. */
1412
- interface TenantSyncRoute {
1413
- readonly transportKey: StructuralLookupKey;
1414
- readonly progress: RouteProgress;
1415
- readonly lastSentAt: Millis | null;
1416
- readonly lastReceivedAt: Millis | null;
1417
- }
1418
-
1419
1428
  /**
1420
1429
  * How long an unanswered request keeps a socket before it is replaced.
1421
1430
  *
@@ -1538,7 +1547,7 @@ interface RouteSkip {
1538
1547
  * message or lack messages stored elsewhere, so it is incomplete.
1539
1548
  */
1540
1549
  interface SettledRoute extends Typed<"Settled"> {
1541
- readonly error: SyncRouteError;
1550
+ readonly skippedError: SyncRouteError;
1542
1551
  readonly completeAt: Millis | null;
1543
1552
  }
1544
1553
 
@@ -1551,19 +1560,15 @@ const routeToPending = (progress: RouteProgress): PendingRoute => {
1551
1560
  case "Pending":
1552
1561
  return progress;
1553
1562
  case "Settled":
1554
- return {
1555
- type: "Pending",
1556
- roundRequired: false,
1557
- failure: null,
1558
- skip: { error: progress.error, isRechecking: false },
1559
- completeAt: progress.completeAt,
1560
- };
1561
1563
  case "Complete":
1562
1564
  return {
1563
1565
  type: "Pending",
1564
1566
  roundRequired: false,
1565
1567
  failure: null,
1566
- skip: null,
1568
+ skip:
1569
+ progress.type === "Settled"
1570
+ ? { error: progress.skippedError, isRechecking: false }
1571
+ : null,
1567
1572
  completeAt: progress.completeAt,
1568
1573
  };
1569
1574
  }
@@ -1572,7 +1577,7 @@ const routeToPending = (progress: RouteProgress): PendingRoute => {
1572
1577
  /**
1573
1578
  * Converts an error to a {@link SyncRouteError}: a caught value becomes an
1574
1579
  * {@link UnknownError}, and a {@link ProtocolInvalidDataError} leaves out its
1575
- * data.
1580
+ * data, which can be a whole frame.
1576
1581
  */
1577
1582
  const errorToSyncRouteError = (
1578
1583
  error:
@@ -1583,8 +1588,6 @@ const errorToSyncRouteError = (
1583
1588
  | Typed<"SyncFailed">,
1584
1589
  at: Millis,
1585
1590
  ): SyncRouteError => {
1586
- // A caught value inside an error becomes an UnknownError, and a
1587
- // ProtocolInvalidDataError leaves out its data, which can be a whole frame.
1588
1591
  if (error.type === "ProtocolInvalidDataError") {
1589
1592
  const { data: _data, ...rest } = error;
1590
1593
  return { ...rest, error: createUnknownError(rest.error), at };
@@ -1603,7 +1606,10 @@ const failRoute = (
1603
1606
  failure,
1604
1607
  });
1605
1608
 
1606
- type EvoluTenantDeps = SharedWorkerDeps &
1609
+ type EvoluTenantDeps = CreateMessageChannelDep &
1610
+ CreateMessagePortDep &
1611
+ GetSyncTransportIdDep &
1612
+ LockManagerDep &
1607
1613
  PostConsoleEntryOrErrorDep &
1608
1614
  PublishSyncStateDep &
1609
1615
  RefreshAllSyncRoutesDep &
@@ -1611,6 +1617,11 @@ type EvoluTenantDeps = SharedWorkerDeps &
1611
1617
  TabLeaderPortStoreDep &
1612
1618
  TransportsDep;
1613
1619
 
1620
+ interface GetSyncTransportIdDep {
1621
+ /** Returns the ID of a claimed transport, which always exists. */
1622
+ readonly getSyncTransportId: (key: StructuralLookupKey) => SyncTransportId;
1623
+ }
1624
+
1614
1625
  interface PostConsoleEntryOrErrorDep {
1615
1626
  readonly postConsoleEntryOrError: Callback<ConsoleEntryOrError>;
1616
1627
  }
@@ -1655,8 +1666,6 @@ interface TransportsDep {
1655
1666
  >;
1656
1667
  }
1657
1668
 
1658
- export type EvoluInstanceId = Id & Brand<"EvoluInstance">;
1659
-
1660
1669
  // Long enough for another build's tabs to reload and its worker to end.
1661
1670
  const otherBuildRunningReportDelay: PositiveDuration = "3s";
1662
1671
 
@@ -1727,7 +1736,7 @@ export const initSharedWorker =
1727
1736
  port.onMessage = (message) => {
1728
1737
  switch (message.type) {
1729
1738
  case "AnnounceTabLeader": {
1730
- console.setLevel(message.consoleLevel);
1739
+ deps.console.setLevel(message.consoleLevel);
1731
1740
  tabLeaderPortStore.set(port);
1732
1741
  console.info("tabLeaderAnnounced");
1733
1742
  break;
@@ -1782,7 +1791,7 @@ export const initSharedWorker =
1782
1791
  disposer.defer(
1783
1792
  deps.consoleStoreOutputEntry.subscribe(() => {
1784
1793
  const entry = deps.consoleStoreOutputEntry.get();
1785
- if (entry) postConsoleEntryOrError({ type: "ConsoleEntry", entry });
1794
+ if (entry) postConsoleEntry(consoleEntryOrErrorBroadcastChannel, entry);
1786
1795
  }),
1787
1796
  );
1788
1797
 
@@ -1837,85 +1846,23 @@ export const initSharedWorker =
1837
1846
  connection,
1838
1847
  }),
1839
1848
  );
1849
+ const tenants = [...currentTenantsByName.values()].map((tenant) =>
1850
+ tenant.getSyncTenant(),
1851
+ );
1840
1852
  // A grown timeout lasts while a request is outstanding on the socket
1841
1853
  // or a database that has not refused startup has an unsettled route
1842
1854
  // through it. Every change to either publishes, including a route that
1843
1855
  // goes away without settling.
1844
1856
  const unsettledTransportIds = new Set<SyncTransportId>();
1845
- const tenants = [...currentTenantsByName.values()].map(
1846
- (tenant): SyncTenant => {
1847
- const state = tenant.getSyncTenant();
1848
- if (state.type === "Refused") return state;
1849
- return {
1850
- type: "Active",
1851
- name: state.name,
1852
- owners: state.owners.map((owner): SyncTenantOwner =>
1853
- owner.type === "Readonly"
1854
- ? {
1855
- type: "Readonly",
1856
- ownerId: owner.ownerId,
1857
- transportIds: owner.transportKeys.flatMap((key) => {
1858
- const entry = transportsByKey.get(key);
1859
- return entry ? [entry.id] : [];
1860
- }),
1861
- }
1862
- : {
1863
- type: "Writable",
1864
- ownerId: owner.ownerId,
1865
- routes: owner.routes.flatMap(
1866
- ({
1867
- transportKey,
1868
- progress,
1869
- lastSentAt,
1870
- lastReceivedAt,
1871
- }): Array<SyncRoute> => {
1872
- const entry = transportsByKey.get(transportKey);
1873
- if (!entry) return [];
1874
- const transportId = entry.id;
1875
- if (progress.type !== "Pending") {
1876
- // Only a sent round settles a route or completes
1877
- // it.
1878
- assertNonNullable(lastSentAt);
1879
- return [
1880
- progress.type === "Complete"
1881
- ? {
1882
- type: "Complete",
1883
- transportId,
1884
- completeAt: progress.completeAt,
1885
- lastSentAt,
1886
- lastReceivedAt,
1887
- }
1888
- : {
1889
- type: "Settled",
1890
- transportId,
1891
- skippedError: progress.error,
1892
- completeAt: progress.completeAt,
1893
- lastSentAt,
1894
- lastReceivedAt,
1895
- },
1896
- ];
1897
- }
1898
- // Only a database that has not refused startup
1899
- // publishes routes.
1900
- unsettledTransportIds.add(transportId);
1901
- return [
1902
- {
1903
- type: "Pending",
1904
- transportId,
1905
- failure: progress.failure,
1906
- skippedError: progress.skip?.error ?? null,
1907
- completeAt: progress.completeAt,
1908
- lastSentAt,
1909
- lastReceivedAt,
1910
- },
1911
- ];
1912
- },
1913
- ),
1914
- },
1915
- ),
1916
- };
1917
- },
1918
- );
1857
+ for (const tenant of tenants) {
1858
+ if (tenant.type !== "Active") continue;
1859
+ for (const owner of tenant.owners) {
1860
+ if (owner.type !== "Writable") continue;
1861
+ for (const route of owner.routes)
1862
+ if (route.type === "Pending")
1863
+ unsettledTransportIds.add(route.transportId);
1864
+ }
1865
+ }
1919
1866
  for (const entry of transportsByKey.values())
1920
1867
  if (
1921
1868
  entry.outstandingByOwnerId.size === 0 &&
@@ -1959,7 +1906,9 @@ export const initSharedWorker =
1959
1906
  shortestRemaining = remaining;
1960
1907
  }
1961
1908
  if (!isTimedOut) {
1962
- if (shortestRemaining === null) return;
1909
+ // Emptying outstandingByOwnerId clears this timer, so an owner is
1910
+ // still outstanding.
1911
+ assertNonNullable(shortestRemaining);
1963
1912
  // Timer delays use integer milliseconds; round up to wait at least
1964
1913
  // the remaining fractional duration.
1965
1914
  armSyncRequestTimeout(
@@ -1994,7 +1943,7 @@ export const initSharedWorker =
1994
1943
  const syncRequests: SyncRequests = {
1995
1944
  noteSent: (ownerId, key) => {
1996
1945
  const entry = transportsByKey.get(key);
1997
- if (!entry) return;
1946
+ assertNonNullable(entry);
1998
1947
  const outstanding = entry.outstandingByOwnerId.get(ownerId);
1999
1948
  if (outstanding) outstanding.count++;
2000
1949
  else
@@ -2021,6 +1970,7 @@ export const initSharedWorker =
2021
1970
  >(
2022
1971
  (transport) => async (run) => {
2023
1972
  const key = structuralLookup(transport);
1973
+ const target: SyncTarget = { type: "Transport", key };
2024
1974
  // The query carries the owner ID; WebSocket accepts URLs that
2025
1975
  // URL() rejects, so the label is derived without parsing.
2026
1976
  const queryIndex = transport.url.indexOf("?");
@@ -2038,10 +1988,10 @@ export const initSharedWorker =
2038
1988
  };
2039
1989
  await using disposer = new AsyncDisposableStack();
2040
1990
  // Registered before the socket, so an aborted creation also
2041
- // forgets the transport.
1991
+ // forgets the transport. No claim exists while a transport is
1992
+ // created or torn down, so neither changes a route.
2042
1993
  disposer.defer(() => {
2043
1994
  transportsByKey.delete(key);
2044
- refreshAllSyncRoutes();
2045
1995
  publishSyncState();
2046
1996
  });
2047
1997
  transportsByKey.set(key, entry);
@@ -2073,13 +2023,8 @@ export const initSharedWorker =
2073
2023
  ownerIds: [...ownerIds],
2074
2024
  });
2075
2025
 
2076
- const target: SyncTarget = {
2077
- type: "Transport",
2078
- key: structuralLookup(transport),
2079
- };
2080
- forEachTenant((tenant) => {
2026
+ for (const tenant of currentTenantsByName.values())
2081
2027
  tenant.requestCreateSyncMessages(ownerIds, target);
2082
- });
2083
2028
  refreshAllSyncRoutes();
2084
2029
  },
2085
2030
 
@@ -2119,7 +2064,7 @@ export const initSharedWorker =
2119
2064
  publishSyncState();
2120
2065
  },
2121
2066
 
2122
- onMessage(data) {
2067
+ onMessage: (data) => {
2123
2068
  if (!(data instanceof ArrayBuffer)) return;
2124
2069
 
2125
2070
  const message = new Uint8Array(data);
@@ -2159,16 +2104,12 @@ export const initSharedWorker =
2159
2104
  publishSyncState();
2160
2105
  }
2161
2106
 
2162
- forEachTenant((tenant) => {
2107
+ for (const tenant of currentTenantsByName.values())
2163
2108
  tenant.requestApplySyncMessage(
2164
2109
  headerResult.value.ownerId,
2165
2110
  message,
2166
- {
2167
- type: "Transport",
2168
- key: structuralLookup(transport),
2169
- },
2111
+ target,
2170
2112
  );
2171
- });
2172
2113
  // Do not complete a sibling after decrementing the shared
2173
2114
  // counter but before its apply has been queued.
2174
2115
  refreshAllSyncRoutes();
@@ -2178,18 +2119,15 @@ export const initSharedWorker =
2178
2119
  disposer.use(socket);
2179
2120
  // LIFO: the transport drops its timer and its socket reference
2180
2121
  // before the socket is disposed. `disposable` guards every method
2181
- // of the socket the claims lease, and disposing the socket awaits
2182
- // its retry, so this entry stays registered meanwhile. It keeps its
2122
+ // of the socket lent to claims, and disposing the socket awaits its
2123
+ // retry, so this entry stays registered meanwhile. It keeps its
2183
2124
  // last connection and holds no socket to reconnect.
2184
2125
  disposer.defer(() => {
2185
2126
  clearSyncRequestTimeout(entry);
2186
2127
  entry.socket = null;
2187
- refreshAllSyncRoutes();
2188
2128
  });
2189
2129
  const disposables = disposer.move();
2190
2130
  entry.socket = socket;
2191
- refreshAllSyncRoutes();
2192
- publishSyncState();
2193
2131
  // `disposable` replaces the socket's disposal method in place, but
2194
2132
  // `disposer.use` above already captured the original, so disposing
2195
2133
  // `disposables` disposes the socket rather than recursing.
@@ -2202,9 +2140,8 @@ export const initSharedWorker =
2202
2140
  type: "Transport",
2203
2141
  key: structuralLookup(transport),
2204
2142
  };
2205
- forEachTenant((tenant) => {
2143
+ for (const tenant of currentTenantsByName.values())
2206
2144
  tenant.requestCreateSyncMessages(new Set([ownerId]), target);
2207
- });
2208
2145
  }
2209
2146
  refreshAllSyncRoutes();
2210
2147
  },
@@ -2227,6 +2164,11 @@ export const initSharedWorker =
2227
2164
  const sharedWorkerRun = disposer.use(
2228
2165
  run.create({
2229
2166
  ...deps,
2167
+ getSyncTransportId: (key: StructuralLookupKey) => {
2168
+ const entry = transportsByKey.get(key);
2169
+ assertNonNullable(entry);
2170
+ return entry.id;
2171
+ },
2230
2172
  postConsoleEntryOrError,
2231
2173
  publishSyncState,
2232
2174
  refreshAllSyncRoutes,
@@ -2239,17 +2181,13 @@ export const initSharedWorker =
2239
2181
  const tenantsByName = disposer.use(
2240
2182
  await sharedWorkerRun.ok(
2241
2183
  createSharedResourceByKey(
2242
- (message: ExtractTyped<SharedWorkerInput, "CreateEvolu">) => {
2243
- const memoryOnly =
2244
- message.memoryOnly ||
2245
- platformDevicePersistence === "NotPersisted";
2246
- return createEvoluTenant(
2247
- { ...message, memoryOnly },
2248
- memoryOnly ? "NotPersisted" : platformDevicePersistence,
2184
+ (message: ExtractTyped<SharedWorkerInput, "CreateEvolu">) =>
2185
+ createEvoluTenant(
2186
+ message,
2187
+ message.memoryOnly ? "NotPersisted" : platformDevicePersistence,
2249
2188
  currentTenantsByName,
2250
2189
  workerId,
2251
- );
2252
- },
2190
+ ),
2253
2191
  {
2254
2192
  idleDisposeAfter: "3s",
2255
2193
  lookup: (message) => message.name,
@@ -2260,12 +2198,6 @@ export const initSharedWorker =
2260
2198
 
2261
2199
  sharedWorkerReady.resolve();
2262
2200
 
2263
- const forEachTenant = (
2264
- callback: Callback<BorrowedResource<EvoluTenant>>,
2265
- ): void => {
2266
- for (const tenant of currentTenantsByName.values()) callback(tenant);
2267
- };
2268
-
2269
2201
  return ok(disposer.move());
2270
2202
  };
2271
2203
 
@@ -2276,7 +2208,6 @@ const createEvoluTenant =
2276
2208
  consoleLevel,
2277
2209
  sqliteSchema,
2278
2210
  encryptionKey,
2279
- memoryOnly,
2280
2211
  }: ExtractTyped<SharedWorkerInput, "CreateEvolu">,
2281
2212
  devicePersistence: DevicePersistence,
2282
2213
  currentTenantsByName: Map<Name, BorrowedResource<EvoluTenant>>,
@@ -2288,6 +2219,7 @@ const createEvoluTenant =
2288
2219
 
2289
2220
  const { deps } = run;
2290
2221
  const console = deps.console.child(name).child("SharedWorker");
2222
+ console.setLevel(consoleLevel);
2291
2223
 
2292
2224
  interface EvoluInstance extends AsyncDisposable {
2293
2225
  readonly id: EvoluInstanceId;
@@ -2296,11 +2228,9 @@ const createEvoluTenant =
2296
2228
  SyncOwner,
2297
2229
  Array<ClaimLease | null>
2298
2230
  >;
2299
- readonly onDisposed: () => void;
2300
2231
  readonly port: MessagePort<EvoluOutput, EvoluInput>;
2301
2232
  readonly tabPort: TabPort;
2302
- readonly useOwnerMutex: Mutex;
2303
- rowsByQuery: Map<Query, ReadonlyArray<Row>>;
2233
+ readonly rowsByQuery: Map<Query, ReadonlyArray<Row>>;
2304
2234
  }
2305
2235
 
2306
2236
  const instancesById = disposer.adopt(
@@ -2313,17 +2243,110 @@ const createEvoluTenant =
2313
2243
  },
2314
2244
  );
2315
2245
 
2316
- let dbWorkerPort = null as MessagePort<
2317
- DbWorkerInput,
2318
- DbWorkerOutput
2319
- > | null;
2320
2246
  const dbWorkerInited = Promise.withResolvers<void>();
2321
2247
 
2248
+ // Sync requests keep their target or source on the entry: the DbWorker
2249
+ // does not need it, and a replacement leader replays the same entry.
2250
+ type QueueEntry =
2251
+ | {
2252
+ readonly type: "Read";
2253
+ readonly request: ExtractTyped<DbWorkerReadRequest, "ForEvolu">;
2254
+ }
2255
+ | {
2256
+ readonly type: "Write";
2257
+ readonly request: ExtractTyped<DbWorkerWriteRequest, "ForEvolu">;
2258
+ /** Owners of its changes to replicated tables. */
2259
+ readonly replicatedOwnerIds: ReadonlySet<OwnerId>;
2260
+ capturedInputs?: CapturedWriteInputs;
2261
+ }
2262
+ | {
2263
+ readonly type: "CreateSyncMessages";
2264
+ readonly request: ExtractTyped<
2265
+ DbWorkerReadRequest,
2266
+ "ForSharedWorker"
2267
+ >;
2268
+ readonly target: SyncTarget;
2269
+ }
2270
+ | {
2271
+ readonly type: "ApplySyncMessage";
2272
+ readonly request: ExtractTyped<
2273
+ DbWorkerWriteRequest,
2274
+ "ForSharedWorker"
2275
+ >;
2276
+ readonly source: ApplySyncMessageSource;
2277
+ capturedInputs?: CapturedWriteInputs;
2278
+ };
2279
+ interface CapturedWriteInputs {
2280
+ readonly clock: Timestamp;
2281
+ readonly now: Millis;
2282
+ }
2283
+
2284
+ /**
2285
+ * The tenant's database worker: `Starting` until one leads, then `Leading`,
2286
+ * which a replacement leader replaces. `Refused` is final, and `Disposed`
2287
+ * changes only to `Refused`, when a worker requested earlier refuses
2288
+ * startup.
2289
+ */
2290
+ type DbWorkerState =
2291
+ Typed<"Starting"> | LeadingDbWorker | RefusedDbWorker | Typed<"Disposed">;
2292
+
2293
+ interface LeadingDbWorker extends Typed<"Leading"> {
2294
+ readonly port: MessagePort<DbWorkerInput, DbWorkerOutput>;
2295
+ /** The greatest clock the tenant's leading database workers reported. */
2296
+ clock: Timestamp;
2297
+ /** The dispatched queue head, which stays queued until it is answered. */
2298
+ activeDispatch: {
2299
+ readonly entry: QueueEntry;
2300
+ readonly attemptId: Id;
2301
+ } | null;
2302
+ }
2303
+
2304
+ interface RefusedDbWorker extends Typed<"Refused"> {
2305
+ readonly error: UnsupportedDbVersionError;
2306
+ }
2307
+
2308
+ let dbWorker: DbWorkerState = { type: "Starting" };
2309
+ const queue: Array<QueueEntry> = [];
2310
+ const pendingWriteCountByOwnerId = new Map<OwnerId, number>();
2311
+ const pendingApplyCountForAllRoutesByOwnerId = new Map<OwnerId, number>();
2312
+ // Queued applies of relay frames, counted per owner and source transport.
2313
+ const ownerTransportApplyRelation = createRefCountedRelation<
2314
+ OwnerId,
2315
+ StructuralLookupKey
2316
+ >();
2317
+
2318
+ // Each tab is told once during this tenant's lifetime, through its own
2319
+ // connection. Recreating the tenant after idle disposal retries startup
2320
+ // and may report the refusal again.
2321
+ const refusedTabPorts = new WeakSet<TabPort>();
2322
+
2323
+ interface RouteState {
2324
+ progress: RouteProgress;
2325
+ lastSentAt: Millis | null;
2326
+ lastReceivedAt: Millis | null;
2327
+ }
2328
+ const routesByOwnerIdByKey = new Map<
2329
+ StructuralLookupKey,
2330
+ Map<OwnerId, RouteState>
2331
+ >();
2332
+
2333
+ // Disposal does not wait for the DbWorker. It holds the database lock
2334
+ // until Dispose arrives, its tab closes, or this worker ends, and the next
2335
+ // DbWorker for this database, of this worker or another build, waits for
2336
+ // that lock before it reads the clock. A requested DbWorker that reports
2337
+ // in later gets Dispose, and a refused tenant stays refused, so it keeps
2338
+ // dropping requests.
2339
+ disposer.defer(() => {
2340
+ if (dbWorker.type === "Leading")
2341
+ dbWorker.port.postMessage({ type: "Dispose" });
2342
+ if (dbWorker.type !== "Refused") dbWorker = { type: "Disposed" };
2343
+ });
2344
+
2322
2345
  const initDbWorker = (): void => {
2323
2346
  // Without a tab leader yet, the store subscription starts the DbWorker
2324
2347
  // once a tab announces itself.
2325
2348
  const tabLeaderPort = deps.tabLeaderPortStore.get();
2326
- if (startupError || !tabLeaderPort) return;
2349
+ if (dbWorker.type === "Refused" || !tabLeaderPort) return;
2327
2350
 
2328
2351
  const dbWorkerChannel = deps.createMessageChannel<
2329
2352
  DbWorkerOutput,
@@ -2334,8 +2357,10 @@ const createEvoluTenant =
2334
2357
  currentDbWorkerPort.onMessage = (message) => {
2335
2358
  switch (message.type) {
2336
2359
  case "LeaderAcquired": {
2337
- assertNotSame(dbWorkerPort, currentDbWorkerPort);
2338
- if (startupError || isDisposing) {
2360
+ const previous = dbWorker;
2361
+ if (previous.type === "Leading")
2362
+ assertNotSame(previous.port, currentDbWorkerPort);
2363
+ if (previous.type === "Refused" || previous.type === "Disposed") {
2339
2364
  // This worker was requested before the refusal or before
2340
2365
  // disposal started. The tenant will not use it, so let it
2341
2366
  // release the database lock.
@@ -2343,32 +2368,30 @@ const createEvoluTenant =
2343
2368
  currentDbWorkerPort[Symbol.dispose]();
2344
2369
  break;
2345
2370
  }
2346
- const replacesLeader = dbWorkerPort !== null;
2347
- dbWorkerPort?.[Symbol.dispose]();
2348
- dbWorkerPort = currentDbWorkerPort;
2349
- activeDispatch = null;
2350
- // A replacement may advance the clock by releasing quarantine, or
2351
- // start behind it with an empty memoryOnly database. Keep the
2352
- // greater clock; pending writes retain their captured inputs.
2353
- if (
2354
- sessionClock === null ||
2355
- orderTimestamp(sessionClock, message.clock) === -1
2356
- ) {
2357
- sessionClock = message.clock;
2358
- }
2371
+ if (previous.type === "Leading") previous.port[Symbol.dispose]();
2372
+ dbWorker = {
2373
+ type: "Leading",
2374
+ port: currentDbWorkerPort,
2375
+ // A replacement may advance the clock by releasing quarantine,
2376
+ // or start behind it with an empty memoryOnly database. Keep the
2377
+ // greater clock; pending writes retain their captured inputs.
2378
+ clock:
2379
+ previous.type !== "Leading" ||
2380
+ orderTimestamp(previous.clock, message.clock) === -1
2381
+ ? message.clock
2382
+ : previous.clock,
2383
+ activeDispatch: null,
2384
+ };
2359
2385
  // A replacement leader may have committed writes whose responses
2360
2386
  // were lost, and may have released quarantine at startup. A lost
2361
2387
  // response may also have reported stored owner messages, so the
2362
2388
  // owners reconcile through every transport again.
2363
- if (replacesLeader) {
2389
+ if (previous.type === "Leading") {
2364
2390
  refreshQueries();
2365
- const usedOwnerIds = new Set<OwnerId>();
2366
- for (const instance of instancesById.values()) {
2367
- for (const { owner } of instance.ownerRegistrations.keys()) {
2368
- usedOwnerIds.add(owner.id);
2369
- }
2370
- }
2371
- requestCreateSyncMessages(usedOwnerIds, allTransports);
2391
+ requestCreateSyncMessages(
2392
+ new Set(getSyncOwners().map(({ ownerId }) => ownerId)),
2393
+ allTransports,
2394
+ );
2372
2395
  }
2373
2396
  console.info("leaderAcquired");
2374
2397
  dbWorkerInited.resolve();
@@ -2376,21 +2399,20 @@ const createEvoluTenant =
2376
2399
  break;
2377
2400
  }
2378
2401
  case "LeaderRefused": {
2379
- assertNotSame(dbWorkerPort, currentDbWorkerPort);
2380
2402
  // The worker refused startup and is releasing its resources.
2381
2403
  // Requests stay unanswered until their instances are disposed.
2382
2404
  // Keep the tenant unavailable, tell each connected tab once, and
2383
2405
  // tell tabs that connect later without starting another worker.
2384
- dbWorkerPort?.[Symbol.dispose]();
2385
- dbWorkerPort = null;
2406
+ if (dbWorker.type === "Leading") {
2407
+ assertNotSame(dbWorker.port, currentDbWorkerPort);
2408
+ dbWorker.port[Symbol.dispose]();
2409
+ }
2386
2410
  currentDbWorkerPort[Symbol.dispose]();
2387
- activeDispatch = null;
2388
2411
  queue.length = 0;
2389
2412
  pendingWriteCountByOwnerId.clear();
2390
2413
  pendingApplyCountForAllRoutesByOwnerId.clear();
2391
2414
  ownerTransportApplyRelation.clear();
2392
- startupError = message.error;
2393
- refreshSyncRoutes();
2415
+ dbWorker = { type: "Refused", error: message.error };
2394
2416
  deps.publishSyncState();
2395
2417
  console.info("leaderRefused", message.error);
2396
2418
  for (const instance of instancesById.values()) {
@@ -2400,20 +2422,22 @@ const createEvoluTenant =
2400
2422
  break;
2401
2423
  }
2402
2424
  case "OnQueuedResponse": {
2403
- if (activeDispatch?.attemptId !== message.attemptId) return;
2404
- const { entry } = activeDispatch;
2425
+ const leading = dbWorker;
2426
+ if (
2427
+ leading.type !== "Leading" ||
2428
+ leading.activeDispatch?.attemptId !== message.attemptId
2429
+ )
2430
+ return;
2431
+ const { entry } = leading.activeDispatch;
2405
2432
  const { response } = message;
2433
+ // Replays report their computed clock, which a replacement leader
2434
+ // may have passed at startup. Keep the greater clock.
2406
2435
  if (
2407
- response.message.type === "Mutate" ||
2408
- response.message.type === "ApplySyncMessage"
2409
- ) {
2410
- // Replays report their computed clock, which a replacement
2411
- // leader may have passed at startup. Keep the greater clock.
2412
- assertNonNullable(sessionClock);
2413
- if (orderTimestamp(sessionClock, response.message.clock) === -1) {
2414
- sessionClock = response.message.clock;
2415
- }
2416
- }
2436
+ (response.message.type === "Mutate" ||
2437
+ response.message.type === "ApplySyncMessage") &&
2438
+ orderTimestamp(leading.clock, response.message.clock) === -1
2439
+ )
2440
+ leading.clock = response.message.clock;
2417
2441
  switch (entry.type) {
2418
2442
  case "Read":
2419
2443
  case "Write":
@@ -2431,7 +2455,7 @@ const createEvoluTenant =
2431
2455
  const head = queue.shift();
2432
2456
  assertNonNullable(head);
2433
2457
  updatePendingWork(head, -1);
2434
- activeDispatch = null;
2458
+ leading.activeDispatch = null;
2435
2459
  // Follow-up uploads and retries are already queued or sent. Only
2436
2460
  // now can removing the completed head establish convergence.
2437
2461
  refreshSyncRoutes();
@@ -2440,6 +2464,8 @@ const createEvoluTenant =
2440
2464
  runQueue();
2441
2465
  break;
2442
2466
  }
2467
+ default:
2468
+ exhaustiveCheck(message);
2443
2469
  }
2444
2470
  };
2445
2471
 
@@ -2450,7 +2476,7 @@ const createEvoluTenant =
2450
2476
  consoleLevel,
2451
2477
  sqliteSchema,
2452
2478
  encryptionKey,
2453
- memoryOnly,
2479
+ memoryOnly: devicePersistence === "NotPersisted",
2454
2480
  sharedWorkerId: workerId,
2455
2481
  port: dbWorkerChannel.port1.native,
2456
2482
  },
@@ -2465,48 +2491,6 @@ const createEvoluTenant =
2465
2491
  }
2466
2492
  };
2467
2493
 
2468
- // Sync requests keep their target or source on the entry: the DbWorker
2469
- // does not need it, and a replacement leader replays the same entry.
2470
- type QueueEntry =
2471
- | {
2472
- readonly type: "Read";
2473
- readonly request: ExtractTyped<DbWorkerReadRequest, "ForEvolu">;
2474
- }
2475
- | {
2476
- readonly type: "Write";
2477
- readonly request: ExtractTyped<DbWorkerWriteRequest, "ForEvolu">;
2478
- /** Owners of its changes to replicated tables. */
2479
- readonly replicatedOwnerIds: ReadonlySet<OwnerId>;
2480
- now?: Millis;
2481
- clock?: Timestamp;
2482
- }
2483
- | {
2484
- readonly type: "CreateSyncMessages";
2485
- readonly request: ExtractTyped<
2486
- DbWorkerReadRequest,
2487
- "ForSharedWorker"
2488
- >;
2489
- readonly target: SyncTarget;
2490
- }
2491
- | {
2492
- readonly type: "ApplySyncMessage";
2493
- readonly request: ExtractTyped<
2494
- DbWorkerWriteRequest,
2495
- "ForSharedWorker"
2496
- >;
2497
- readonly source: ApplySyncMessageSource;
2498
- now?: Millis;
2499
- clock?: Timestamp;
2500
- };
2501
- const queue: Array<QueueEntry> = [];
2502
- const pendingWriteCountByOwnerId = new Map<OwnerId, number>();
2503
- const pendingApplyCountForAllRoutesByOwnerId = new Map<OwnerId, number>();
2504
- // Queued applies of relay frames, counted per owner and source transport.
2505
- const ownerTransportApplyRelation = createRefCountedRelation<
2506
- OwnerId,
2507
- StructuralLookupKey
2508
- >();
2509
-
2510
2494
  const updatePendingCount = <K>(
2511
2495
  counts: Map<K, number>,
2512
2496
  key: K,
@@ -2560,13 +2544,6 @@ const createEvoluTenant =
2560
2544
  queue.push(entry);
2561
2545
  };
2562
2546
 
2563
- let sessionClock: Timestamp | null = null;
2564
- let startupError: UnsupportedDbVersionError | null = null;
2565
- let isDisposing = false;
2566
- // Each tab is told once during this tenant's lifetime, through its own
2567
- // connection. Recreating the tenant after idle disposal retries startup
2568
- // and may report the refusal again.
2569
- const refusedTabPorts = new WeakSet<TabPort>();
2570
2547
  const reportRefusal = (
2571
2548
  tabPort: TabPort,
2572
2549
  error: UnsupportedDbVersionError,
@@ -2575,31 +2552,31 @@ const createEvoluTenant =
2575
2552
  refusedTabPorts.add(tabPort);
2576
2553
  tabPort.postMessage({ type: "Error", error });
2577
2554
  };
2578
- let activeDispatch: {
2579
- readonly entry: QueueEntry;
2580
- readonly attemptId: Id;
2581
- } | null = null;
2582
-
2583
2555
  const runQueue = (): void => {
2584
- if (activeDispatch || !isNonEmptyArray(queue) || !dbWorkerPort) return;
2585
- assertNonNullable(sessionClock);
2556
+ if (
2557
+ dbWorker.type !== "Leading" ||
2558
+ dbWorker.activeDispatch ||
2559
+ !isNonEmptyArray(queue)
2560
+ )
2561
+ return;
2586
2562
  const entry = firstInArray(queue);
2587
2563
  const attemptId = createId(run.deps);
2588
- activeDispatch = { entry, attemptId };
2564
+ dbWorker.activeDispatch = { entry, attemptId };
2589
2565
  if (entry.type === "Write" || entry.type === "ApplySyncMessage") {
2590
2566
  // A write captures its inputs on first dispatch, so a retry after
2591
2567
  // leader replacement reproduces the same timestamps.
2592
- entry.now ??= run.deps.time.now();
2593
- entry.clock ??= sessionClock;
2594
- dbWorkerPort.postMessage({
2568
+ entry.capturedInputs ??= {
2569
+ clock: dbWorker.clock,
2570
+ now: run.deps.time.now(),
2571
+ };
2572
+ dbWorker.port.postMessage({
2595
2573
  type: "Request",
2596
2574
  attemptId,
2597
2575
  request: entry.request,
2598
- clock: entry.clock,
2599
- now: entry.now,
2576
+ ...entry.capturedInputs,
2600
2577
  });
2601
2578
  } else {
2602
- dbWorkerPort.postMessage({
2579
+ dbWorker.port.postMessage({
2603
2580
  type: "Request",
2604
2581
  attemptId,
2605
2582
  request: entry.request,
@@ -2607,18 +2584,6 @@ const createEvoluTenant =
2607
2584
  }
2608
2585
  };
2609
2586
 
2610
- // Disposal does not wait for the DbWorker. It holds the database lock
2611
- // until Dispose arrives, its tab closes, or this worker ends, and the next
2612
- // DbWorker for this database, of this worker or another build, waits for
2613
- // that lock before it reads the clock. A requested DbWorker that reports
2614
- // in later gets Dispose from the isDisposing check.
2615
- disposer.defer(() => {
2616
- isDisposing = true;
2617
- dbWorkerPort?.postMessage({ type: "Dispose" });
2618
- dbWorkerPort = null;
2619
- activeDispatch = null;
2620
- });
2621
-
2622
2587
  const handleResponseForEvolu = (
2623
2588
  response: ExtractTyped<DbWorkerQueuedResponse, "ForEvolu">,
2624
2589
  first: DbWorkerRequest,
@@ -2631,19 +2596,16 @@ const createEvoluTenant =
2631
2596
  case "Mutate":
2632
2597
  case "Query": {
2633
2598
  if (instance) {
2634
- const nextRowsByQuery = new Map(instance.rowsByQuery);
2635
2599
  const patchesByQuery = new Map<Query, ReadonlyArray<Patch>>();
2636
2600
 
2637
2601
  for (const [query, rows] of response.message.rowsByQuery) {
2638
- nextRowsByQuery.set(query, rows);
2639
2602
  patchesByQuery.set(
2640
2603
  query,
2641
2604
  makePatches(instance.rowsByQuery.get(query), rows),
2642
2605
  );
2606
+ instance.rowsByQuery.set(query, rows);
2643
2607
  }
2644
2608
 
2645
- instance.rowsByQuery = nextRowsByQuery;
2646
-
2647
2609
  instance.port.postMessage({
2648
2610
  type: "OnPatchesByQuery",
2649
2611
  patchesByQuery,
@@ -2740,28 +2702,10 @@ const createEvoluTenant =
2740
2702
  }
2741
2703
  };
2742
2704
 
2743
- interface RouteState {
2744
- progress: RouteProgress;
2745
- lastSentAt: Millis | null;
2746
- lastReceivedAt: Millis | null;
2747
- }
2748
- const routesByOwnerIdByKey = new Map<
2749
- StructuralLookupKey,
2750
- Map<OwnerId, RouteState>
2751
- >();
2752
-
2753
- const getRoute = (
2754
- ownerId: OwnerId,
2755
- key: StructuralLookupKey,
2756
- ): RouteState => {
2757
- let routesByOwnerId = routesByOwnerIdByKey.get(key);
2758
- if (!routesByOwnerId) {
2759
- routesByOwnerId = new Map();
2760
- routesByOwnerIdByKey.set(key, routesByOwnerId);
2761
- }
2762
- let route = routesByOwnerId.get(ownerId);
2763
- if (!route) {
2764
- route = {
2705
+ const getRoute = (ownerId: OwnerId, key: StructuralLookupKey): RouteState =>
2706
+ routesByOwnerIdByKey
2707
+ .getOrInsertComputed(key, () => new Map())
2708
+ .getOrInsertComputed(ownerId, () => ({
2765
2709
  progress: {
2766
2710
  type: "Pending",
2767
2711
  roundRequired: true,
@@ -2771,11 +2715,7 @@ const createEvoluTenant =
2771
2715
  },
2772
2716
  lastSentAt: null,
2773
2717
  lastReceivedAt: null,
2774
- };
2775
- routesByOwnerId.set(ownerId, route);
2776
- }
2777
- return route;
2778
- };
2718
+ }));
2779
2719
 
2780
2720
  const getSyncOwners = () => {
2781
2721
  const ownersById = new Map<OwnerId, { writable: boolean }>();
@@ -2849,7 +2789,7 @@ const createEvoluTenant =
2849
2789
  : pending.skip && !pending.skip.isRechecking
2850
2790
  ? {
2851
2791
  type: "Settled",
2852
- error: pending.skip.error,
2792
+ skippedError: pending.skip.error,
2853
2793
  completeAt: pending.completeAt,
2854
2794
  }
2855
2795
  : route.progress.type === "Complete"
@@ -2876,8 +2816,9 @@ const createEvoluTenant =
2876
2816
  );
2877
2817
  const { failedOwnerIds } = response.message;
2878
2818
  if (failedOwnerIds.size === 0) break;
2879
- // A retry would likely fail the same way, so the routes wait for an
2880
- // explicit request or a reopen.
2819
+ // A retry would likely fail the same way, so the routes wait for the
2820
+ // next round another event requests, such as an explicit request or
2821
+ // a reopen.
2881
2822
  const now = deps.time.now();
2882
2823
  for (const ownerId of failedOwnerIds) {
2883
2824
  deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
@@ -2961,9 +2902,9 @@ const createEvoluTenant =
2961
2902
  };
2962
2903
  if (failure !== null) {
2963
2904
  // The first failure since the route settled requests one
2964
- // round. Further failures wait for an explicit request or a
2965
- // reopen, even after a converged reply, which may answer
2966
- // another request.
2905
+ // round. Further failures wait for the next round another event
2906
+ // requests, such as an explicit request or a reopen, even after
2907
+ // a converged reply, which may answer another request.
2967
2908
  const requestsRetry =
2968
2909
  route.progress.type !== "Pending" ||
2969
2910
  route.progress.failure === null;
@@ -3170,7 +3111,7 @@ const createEvoluTenant =
3170
3111
  afterQueuedWrites?: boolean;
3171
3112
  } = {},
3172
3113
  ): void => {
3173
- if (startupError) return;
3114
+ if (dbWorker.type === "Refused") return;
3174
3115
  const usedOwnersById = getUsedOwnersById(ownerIds);
3175
3116
  // Opening, storing messages from another transport, a failure, and an
3176
3117
  // explicit request each require a new round before completion. The
@@ -3209,10 +3150,14 @@ const createEvoluTenant =
3209
3150
  ({ type }) => type === "Write" || type === "ApplySyncMessage",
3210
3151
  )
3211
3152
  : -1;
3153
+ const dispatchedEntry =
3154
+ dbWorker.type === "Leading"
3155
+ ? dbWorker.activeDispatch?.entry
3156
+ : undefined;
3212
3157
  const isQueued = queue.some(
3213
3158
  (entry, index) =>
3214
3159
  index > lastWriteIndex &&
3215
- entry !== activeDispatch?.entry &&
3160
+ entry !== dispatchedEntry &&
3216
3161
  entry.type === "CreateSyncMessages" &&
3217
3162
  (entry.target.type === "AllTransports" ||
3218
3163
  (target.type === "Transport" && entry.target.key === target.key)) &&
@@ -3255,21 +3200,12 @@ const createEvoluTenant =
3255
3200
  "writeKey" in syncOwner.owner &&
3256
3201
  !getUsedOwnersById(new Set([ownerId])).has(ownerId);
3257
3202
  // A transport first claimed for the owner starts every tenant's
3258
- // round through onFirstClaimAdded. A joining tenant also reconciles
3259
- // its existing history through the owner's already claimed transports.
3260
- // Later registrations reconcile only transports newly used here:
3261
- // earlier rounds may predate writes from an unregistered instance.
3262
- const claimedKeys = new Set(getClaimedKeys(ownerId));
3263
- const usedKeys = new Set<StructuralLookupKey>();
3264
- for (const { ownerRegistrations } of instancesById.values()) {
3265
- for (const [usedSyncOwner, leases] of ownerRegistrations) {
3266
- if (usedSyncOwner.owner.id !== ownerId) continue;
3267
- if (!leases.some((lease) => lease !== null)) continue;
3268
- for (const transport of usedSyncOwner.transports) {
3269
- usedKeys.add(structuralLookup(transport));
3270
- }
3271
- }
3272
- }
3203
+ // round through onFirstClaimAdded. A tenant's first writable
3204
+ // registration reconciles its existing history through the
3205
+ // transports already claimed for the owner. Later writes upload
3206
+ // through every claimed transport, whichever instance made them, so
3207
+ // later registrations need no round.
3208
+ const claimedKeys = getClaimedKeys(ownerId);
3273
3209
  const leases = instance.ownerRegistrations.getOrInsertComputed(
3274
3210
  syncOwner,
3275
3211
  () => [],
@@ -3289,17 +3225,12 @@ const createEvoluTenant =
3289
3225
  }
3290
3226
  deps.refreshAllSyncRoutes();
3291
3227
  }
3292
- const keysToSync = isFirstWritableUse
3293
- ? claimedKeys
3294
- : syncOwner.transports
3295
- .map(structuralLookup)
3296
- .filter((key) => claimedKeys.has(key) && !usedKeys.has(key));
3297
- for (const key of keysToSync) {
3298
- requestCreateSyncMessages(new Set([ownerId]), {
3299
- type: "Transport",
3300
- key,
3301
- });
3302
- }
3228
+ if (isFirstWritableUse)
3229
+ for (const key of claimedKeys)
3230
+ requestCreateSyncMessages(new Set([ownerId]), {
3231
+ type: "Transport",
3232
+ key,
3233
+ });
3303
3234
  } else {
3304
3235
  const claimLeases = instance.ownerRegistrations.get(syncOwner);
3305
3236
  assertNotUndefined(claimLeases);
@@ -3333,18 +3264,18 @@ const createEvoluTenant =
3333
3264
  const tenant = disposable<EvoluTenant>(
3334
3265
  {
3335
3266
  getSyncTenant: () =>
3336
- startupError
3337
- ? { type: "Refused", name, error: startupError }
3267
+ dbWorker.type === "Refused"
3268
+ ? { type: "Refused", name, error: dbWorker.error }
3338
3269
  : {
3339
3270
  type: "Active",
3340
3271
  name,
3341
3272
  owners: getSyncOwners().map(
3342
- ({ ownerId, writable, transportKeys }): TenantSyncOwner =>
3273
+ ({ ownerId, writable, transportKeys }): SyncTenantOwner =>
3343
3274
  writable
3344
3275
  ? {
3345
3276
  type: "Writable",
3346
3277
  ownerId,
3347
- routes: transportKeys.map((key): TenantSyncRoute => {
3278
+ routes: transportKeys.map((key): SyncRoute => {
3348
3279
  const route = routesByOwnerIdByKey
3349
3280
  .get(key)
3350
3281
  ?.get(ownerId);
@@ -3352,10 +3283,49 @@ const createEvoluTenant =
3352
3283
  // before yielding. Snapshot reads must not create
3353
3284
  // missing routes.
3354
3285
  assertNotUndefined(route);
3355
- return { transportKey: key, ...route };
3286
+ const transportId = deps.getSyncTransportId(key);
3287
+ const { progress, lastSentAt, lastReceivedAt } =
3288
+ route;
3289
+ if (progress.type === "Pending")
3290
+ return {
3291
+ type: "Pending",
3292
+ transportId,
3293
+ failure: progress.failure,
3294
+ skippedError: progress.skip?.error ?? null,
3295
+ completeAt: progress.completeAt,
3296
+ lastSentAt,
3297
+ lastReceivedAt,
3298
+ };
3299
+ // Only a sent round settles a route or completes
3300
+ // it.
3301
+ assertNonNullable(lastSentAt);
3302
+ if (progress.type === "Complete")
3303
+ return {
3304
+ type: "Complete",
3305
+ transportId,
3306
+ completeAt: progress.completeAt,
3307
+ lastSentAt,
3308
+ lastReceivedAt,
3309
+ };
3310
+ // Only a received reply records a skip.
3311
+ assertNonNullable(lastReceivedAt);
3312
+ return {
3313
+ type: "Settled",
3314
+ transportId,
3315
+ skippedError: progress.skippedError,
3316
+ completeAt: progress.completeAt,
3317
+ lastSentAt,
3318
+ lastReceivedAt,
3319
+ };
3356
3320
  }),
3357
3321
  }
3358
- : { type: "Readonly", ownerId, transportKeys },
3322
+ : {
3323
+ type: "Readonly",
3324
+ ownerId,
3325
+ transportIds: transportKeys.map(
3326
+ deps.getSyncTransportId,
3327
+ ),
3328
+ },
3359
3329
  ),
3360
3330
  },
3361
3331
 
@@ -3381,15 +3351,13 @@ const createEvoluTenant =
3381
3351
  message.evoluPort,
3382
3352
  ),
3383
3353
  tabPort,
3384
- onDisposed,
3385
3354
  rowsByQuery: new Map<Query, ReadonlyArray<Row>>(),
3386
- useOwnerMutex: createMutex(),
3387
3355
  [Symbol.asyncDispose]: () => disposer.disposeAsync(),
3388
3356
  };
3389
3357
 
3390
3358
  instancesById.set(instance.id, instance);
3391
3359
 
3392
- disposer.defer(instance.onDisposed);
3360
+ disposer.defer(onDisposed);
3393
3361
 
3394
3362
  disposer.defer(() => {
3395
3363
  for (const leases of instance.ownerRegistrations.values()) {
@@ -3404,6 +3372,7 @@ const createEvoluTenant =
3404
3372
  // aborts queued UseOwner batches and waits for the running one, whose
3405
3373
  // transport claim cannot be aborted, so the release sees every lease.
3406
3374
  const instanceRun = disposer.use(tenantRun.create());
3375
+ const useOwnerMutex = createMutex();
3407
3376
 
3408
3377
  disposer.defer(() => {
3409
3378
  instancesById.delete(instance.id);
@@ -3435,7 +3404,7 @@ const createEvoluTenant =
3435
3404
  });
3436
3405
 
3437
3406
  instance.port.onMessage = (message) => {
3438
- if (startupError) return;
3407
+ if (dbWorker.type === "Refused") return;
3439
3408
  switch (message.type) {
3440
3409
  case "Query":
3441
3410
  case "Export": {
@@ -3467,7 +3436,7 @@ const createEvoluTenant =
3467
3436
  }
3468
3437
  case "UseOwner": {
3469
3438
  void instanceRun(
3470
- instance.useOwnerMutex.withLock(async (run) => {
3439
+ useOwnerMutex.withLock(async (run) => {
3471
3440
  for (const action of message.actions) {
3472
3441
  switch (action.action) {
3473
3442
  case "sync":
@@ -3508,16 +3477,19 @@ const createEvoluTenant =
3508
3477
  );
3509
3478
  break;
3510
3479
  }
3480
+ default:
3481
+ exhaustiveCheck(message);
3511
3482
  }
3512
3483
  };
3513
3484
 
3514
- if (startupError) reportRefusal(instance.tabPort, startupError);
3485
+ if (dbWorker.type === "Refused")
3486
+ reportRefusal(instance.tabPort, dbWorker.error);
3515
3487
  },
3516
3488
 
3517
3489
  requestCreateSyncMessages,
3518
3490
 
3519
3491
  requestApplySyncMessage: (ownerId, inputMessage, source): void => {
3520
- if (startupError) return;
3492
+ if (dbWorker.type === "Refused") return;
3521
3493
  const owner = getUsedOwnersById(new Set([ownerId])).get(ownerId);
3522
3494
  if (!owner) return;
3523
3495
 
@@ -3549,40 +3521,16 @@ const createEvoluTenant =
3549
3521
  return ok(tenant);
3550
3522
  };
3551
3523
 
3552
- // | (Typed<"reset"> & {
3553
- // readonly onCompleteId: CallbackId;
3554
- // readonly reload: boolean;
3555
- // readonly restore?: {
3556
- // readonly sqliteSchema: SqliteSchema;
3557
- // readonly mnemonic: Mnemonic;
3558
- // };
3559
- // })
3560
- // | (Typed<"ensureSqliteSchema"> & {
3561
- // readonly sqliteSchema: SqliteSchema;
3562
- // })
3563
- // | (Typed<"export"> & {
3564
- // readonly onCompleteId: CallbackId;
3565
- // })
3566
- // | (Typed<"useOwner"> & {
3567
- // readonly use: boolean;
3568
- // readonly owner: SyncOwner;
3569
- // });
3570
- // | (Typed<"onReset"> & {
3571
- // readonly onCompleteId: CallbackId;
3572
- // readonly reload: boolean;
3573
- // })
3574
-
3575
3524
  // TODO: SharedWorker follow-ups.
3576
3525
  // - Rotate the node ID when a copied database is detected; see the Duplicate
3577
3526
  // node IDs section in the Timestamp module.
3578
3527
  // - Detect DbWorker and port liveness so a worker-only crash resumes the queue.
3579
3528
  // Defer panicked-worker restart until failure detection and recovery are
3580
- // defined, accounting for SQLite WASM's detection limits. A mutation that
3581
- // throws is answered, but other SQLite operations are expected not to throw.
3529
+ // defined, accounting for SQLite WASM's detection limits. Mutations, round
3530
+ // creation, and frame application are answered when SQLite throws; a query
3531
+ // or an export that throws is not, and stops the database's queue.
3582
3532
  // - Split worker protocol types and the EvoluTenant implementation into focused
3583
3533
  // modules.
3584
- // - Remove the obsolete commented protocol block above.
3585
- // - Propagate invalid protocol messages to sync state.
3586
3534
  // - Bound sync state publishing during a bulk catch-up: every sent and applied
3587
3535
  // frame changes a route timestamp, so each frame broadcasts a full snapshot
3588
3536
  // to every tab. Throttling needs a wall-clock policy and a deterministic way