@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
  *
@@ -314,9 +316,10 @@ import { acquireLeaderLock } from "../LockManager.js";
314
316
  import { createLookupMap, structuralLookup, } from "../Lookup.js";
315
317
  import { createRefCountedRelation } from "../Relation.js";
316
318
  import { createSharedResourceByKey, createSharedResourceByKeyWithClaims, } from "../Resource.js";
317
- import { ok } from "../Result.js";
319
+ import { ok, trySync } from "../Result.js";
318
320
  import { createStore } from "../Store.js";
319
- import { AbortError, createMutex, unabortable, } from "../Task.js";
321
+ import { safelyStringifyUnknownValue } from "../String.js";
322
+ import { AbortError, createMutex, unabortable } from "../Task.js";
320
323
  import { performanceDurationBetween, PositiveMillis, } from "../Time.js";
321
324
  import { createId, id, literal, object, } from "../Type.js";
322
325
  import { createProtocolBroadcastMessagesFromCrdtMessages, createProtocolMessageForUnsubscribe, createProtocolMessageFromCrdtMessages, MessageType, parseProtocolHeader, } from "./Protocol.js";
@@ -324,6 +327,35 @@ import { makePatches, } from "./Query.js";
324
327
  import { isLocalOnlyTable } from "./Schema.js";
325
328
  import { orderTimestamp } from "./Timestamp.js";
326
329
  export const consoleEntryOrErrorBroadcastChannelName = "evolu:console-entry-or-error";
330
+ /**
331
+ * Posts a {@link ConsoleEntry} to tabs on a channel named
332
+ * {@link consoleEntryOrErrorBroadcastChannelName}.
333
+ *
334
+ * An argument that cannot be structured-cloned is replaced: an `Error` by the
335
+ * plain object {@link createUnknownError} makes of it, and any other value, such
336
+ * as an object holding a function, by a string. Logging such a value in a
337
+ * worker therefore does not throw.
338
+ */
339
+ export const postConsoleEntry = (channel, entry) => {
340
+ // A native BroadcastChannel throws a DataCloneError synchronously, into
341
+ // whatever called the console.
342
+ const posted = trySync(() => channel.postMessage({ type: "ConsoleEntry", entry }));
343
+ if (posted.ok)
344
+ return;
345
+ channel.postMessage({
346
+ type: "ConsoleEntry",
347
+ entry: {
348
+ ...entry,
349
+ args: entry.args.map((arg) => {
350
+ if (trySync(() => structuredClone(arg)).ok)
351
+ return arg;
352
+ return arg instanceof Error
353
+ ? createUnknownError(arg).error
354
+ : safelyStringifyUnknownValue(arg);
355
+ }),
356
+ },
357
+ });
358
+ };
327
359
  /** Identifies one running SharedWorker instance. */
328
360
  export const SharedWorkerId = /*#__PURE__*/ id("SharedWorker");
329
361
  /**
@@ -689,19 +721,14 @@ const routeToPending = (progress) => {
689
721
  case "Pending":
690
722
  return progress;
691
723
  case "Settled":
692
- return {
693
- type: "Pending",
694
- roundRequired: false,
695
- failure: null,
696
- skip: { error: progress.error, isRechecking: false },
697
- completeAt: progress.completeAt,
698
- };
699
724
  case "Complete":
700
725
  return {
701
726
  type: "Pending",
702
727
  roundRequired: false,
703
728
  failure: null,
704
- skip: null,
729
+ skip: progress.type === "Settled"
730
+ ? { error: progress.skippedError, isRechecking: false }
731
+ : null,
705
732
  completeAt: progress.completeAt,
706
733
  };
707
734
  }
@@ -709,11 +736,9 @@ const routeToPending = (progress) => {
709
736
  /**
710
737
  * Converts an error to a {@link SyncRouteError}: a caught value becomes an
711
738
  * {@link UnknownError}, and a {@link ProtocolInvalidDataError} leaves out its
712
- * data.
739
+ * data, which can be a whole frame.
713
740
  */
714
741
  const errorToSyncRouteError = (error, at) => {
715
- // A caught value inside an error becomes an UnknownError, and a
716
- // ProtocolInvalidDataError leaves out its data, which can be a whole frame.
717
742
  if (error.type === "ProtocolInvalidDataError") {
718
743
  const { data: _data, ...rest } = error;
719
744
  return { ...rest, error: createUnknownError(rest.error), at };
@@ -785,7 +810,7 @@ export const initSharedWorker = (self) => async (run) => {
785
810
  port.onMessage = (message) => {
786
811
  switch (message.type) {
787
812
  case "AnnounceTabLeader": {
788
- console.setLevel(message.consoleLevel);
813
+ deps.console.setLevel(message.consoleLevel);
789
814
  tabLeaderPortStore.set(port);
790
815
  console.info("tabLeaderAnnounced");
791
816
  break;
@@ -832,7 +857,7 @@ export const initSharedWorker = (self) => async (run) => {
832
857
  disposer.defer(deps.consoleStoreOutputEntry.subscribe(() => {
833
858
  const entry = deps.consoleStoreOutputEntry.get();
834
859
  if (entry)
835
- postConsoleEntryOrError({ type: "ConsoleEntry", entry });
860
+ postConsoleEntry(consoleEntryOrErrorBroadcastChannel, entry);
836
861
  }));
837
862
  const currentTenantsByName = new Map();
838
863
  const transportsByKey = new Map();
@@ -856,76 +881,23 @@ export const initSharedWorker = (self) => async (run) => {
856
881
  label,
857
882
  connection,
858
883
  }));
884
+ const tenants = [...currentTenantsByName.values()].map((tenant) => tenant.getSyncTenant());
859
885
  // A grown timeout lasts while a request is outstanding on the socket
860
886
  // or a database that has not refused startup has an unsettled route
861
887
  // through it. Every change to either publishes, including a route that
862
888
  // goes away without settling.
863
889
  const unsettledTransportIds = new Set();
864
- const tenants = [...currentTenantsByName.values()].map((tenant) => {
865
- const state = tenant.getSyncTenant();
866
- if (state.type === "Refused")
867
- return state;
868
- return {
869
- type: "Active",
870
- name: state.name,
871
- owners: state.owners.map((owner) => owner.type === "Readonly"
872
- ? {
873
- type: "Readonly",
874
- ownerId: owner.ownerId,
875
- transportIds: owner.transportKeys.flatMap((key) => {
876
- const entry = transportsByKey.get(key);
877
- return entry ? [entry.id] : [];
878
- }),
879
- }
880
- : {
881
- type: "Writable",
882
- ownerId: owner.ownerId,
883
- routes: owner.routes.flatMap(({ transportKey, progress, lastSentAt, lastReceivedAt, }) => {
884
- const entry = transportsByKey.get(transportKey);
885
- if (!entry)
886
- return [];
887
- const transportId = entry.id;
888
- if (progress.type !== "Pending") {
889
- // Only a sent round settles a route or completes
890
- // it.
891
- assertNonNullable(lastSentAt);
892
- return [
893
- progress.type === "Complete"
894
- ? {
895
- type: "Complete",
896
- transportId,
897
- completeAt: progress.completeAt,
898
- lastSentAt,
899
- lastReceivedAt,
900
- }
901
- : {
902
- type: "Settled",
903
- transportId,
904
- skippedError: progress.error,
905
- completeAt: progress.completeAt,
906
- lastSentAt,
907
- lastReceivedAt,
908
- },
909
- ];
910
- }
911
- // Only a database that has not refused startup
912
- // publishes routes.
913
- unsettledTransportIds.add(transportId);
914
- return [
915
- {
916
- type: "Pending",
917
- transportId,
918
- failure: progress.failure,
919
- skippedError: progress.skip?.error ?? null,
920
- completeAt: progress.completeAt,
921
- lastSentAt,
922
- lastReceivedAt,
923
- },
924
- ];
925
- }),
926
- }),
927
- };
928
- });
890
+ for (const tenant of tenants) {
891
+ if (tenant.type !== "Active")
892
+ continue;
893
+ for (const owner of tenant.owners) {
894
+ if (owner.type !== "Writable")
895
+ continue;
896
+ for (const route of owner.routes)
897
+ if (route.type === "Pending")
898
+ unsettledTransportIds.add(route.transportId);
899
+ }
900
+ }
929
901
  for (const entry of transportsByKey.values())
930
902
  if (entry.outstandingByOwnerId.size === 0 &&
931
903
  !unsettledTransportIds.has(entry.id))
@@ -962,8 +934,9 @@ export const initSharedWorker = (self) => async (run) => {
962
934
  shortestRemaining = remaining;
963
935
  }
964
936
  if (!isTimedOut) {
965
- if (shortestRemaining === null)
966
- return;
937
+ // Emptying outstandingByOwnerId clears this timer, so an owner is
938
+ // still outstanding.
939
+ assertNonNullable(shortestRemaining);
967
940
  // Timer delays use integer milliseconds; round up to wait at least
968
941
  // the remaining fractional duration.
969
942
  armSyncRequestTimeout(entry, PositiveMillis.orThrow(Math.ceil(shortestRemaining)));
@@ -988,8 +961,7 @@ export const initSharedWorker = (self) => async (run) => {
988
961
  const syncRequests = {
989
962
  noteSent: (ownerId, key) => {
990
963
  const entry = transportsByKey.get(key);
991
- if (!entry)
992
- return;
964
+ assertNonNullable(entry);
993
965
  const outstanding = entry.outstandingByOwnerId.get(ownerId);
994
966
  if (outstanding)
995
967
  outstanding.count++;
@@ -1008,6 +980,7 @@ export const initSharedWorker = (self) => async (run) => {
1008
980
  const env_2 = { stack: [], error: void 0, hasError: false };
1009
981
  try {
1010
982
  const key = structuralLookup(transport);
983
+ const target = { type: "Transport", key };
1011
984
  // The query carries the owner ID; WebSocket accepts URLs that
1012
985
  // URL() rejects, so the label is derived without parsing.
1013
986
  const queryIndex = transport.url.indexOf("?");
@@ -1024,10 +997,10 @@ export const initSharedWorker = (self) => async (run) => {
1024
997
  };
1025
998
  const disposer = __addDisposableResource(env_2, new AsyncDisposableStack(), true);
1026
999
  // Registered before the socket, so an aborted creation also
1027
- // forgets the transport.
1000
+ // forgets the transport. No claim exists while a transport is
1001
+ // created or torn down, so neither changes a route.
1028
1002
  disposer.defer(() => {
1029
1003
  transportsByKey.delete(key);
1030
- refreshAllSyncRoutes();
1031
1004
  publishSyncState();
1032
1005
  });
1033
1006
  transportsByKey.set(key, entry);
@@ -1054,13 +1027,8 @@ export const initSharedWorker = (self) => async (run) => {
1054
1027
  url: transport.url,
1055
1028
  ownerIds: [...ownerIds],
1056
1029
  });
1057
- const target = {
1058
- type: "Transport",
1059
- key: structuralLookup(transport),
1060
- };
1061
- forEachTenant((tenant) => {
1030
+ for (const tenant of currentTenantsByName.values())
1062
1031
  tenant.requestCreateSyncMessages(ownerIds, target);
1063
- });
1064
1032
  refreshAllSyncRoutes();
1065
1033
  },
1066
1034
  onClose: (event) => {
@@ -1089,7 +1057,7 @@ export const initSharedWorker = (self) => async (run) => {
1089
1057
  refreshAllSyncRoutes();
1090
1058
  publishSyncState();
1091
1059
  },
1092
- onMessage(data) {
1060
+ onMessage: (data) => {
1093
1061
  if (!(data instanceof ArrayBuffer))
1094
1062
  return;
1095
1063
  const message = new Uint8Array(data);
@@ -1123,12 +1091,8 @@ export const initSharedWorker = (self) => async (run) => {
1123
1091
  clearSyncRequestTimeout(entry);
1124
1092
  publishSyncState();
1125
1093
  }
1126
- forEachTenant((tenant) => {
1127
- tenant.requestApplySyncMessage(headerResult.value.ownerId, message, {
1128
- type: "Transport",
1129
- key: structuralLookup(transport),
1130
- });
1131
- });
1094
+ for (const tenant of currentTenantsByName.values())
1095
+ tenant.requestApplySyncMessage(headerResult.value.ownerId, message, target);
1132
1096
  // Do not complete a sibling after decrementing the shared
1133
1097
  // counter but before its apply has been queued.
1134
1098
  refreshAllSyncRoutes();
@@ -1137,18 +1101,15 @@ export const initSharedWorker = (self) => async (run) => {
1137
1101
  disposer.use(socket);
1138
1102
  // LIFO: the transport drops its timer and its socket reference
1139
1103
  // before the socket is disposed. `disposable` guards every method
1140
- // of the socket the claims lease, and disposing the socket awaits
1141
- // its retry, so this entry stays registered meanwhile. It keeps its
1104
+ // of the socket lent to claims, and disposing the socket awaits its
1105
+ // retry, so this entry stays registered meanwhile. It keeps its
1142
1106
  // last connection and holds no socket to reconnect.
1143
1107
  disposer.defer(() => {
1144
1108
  clearSyncRequestTimeout(entry);
1145
1109
  entry.socket = null;
1146
- refreshAllSyncRoutes();
1147
1110
  });
1148
1111
  const disposables = disposer.move();
1149
1112
  entry.socket = socket;
1150
- refreshAllSyncRoutes();
1151
- publishSyncState();
1152
1113
  // `disposable` replaces the socket's disposal method in place, but
1153
1114
  // `disposer.use` above already captured the original, so disposing
1154
1115
  // `disposables` disposes the socket rather than recursing.
@@ -1170,9 +1131,8 @@ export const initSharedWorker = (self) => async (run) => {
1170
1131
  type: "Transport",
1171
1132
  key: structuralLookup(transport),
1172
1133
  };
1173
- forEachTenant((tenant) => {
1134
+ for (const tenant of currentTenantsByName.values())
1174
1135
  tenant.requestCreateSyncMessages(new Set([ownerId]), target);
1175
- });
1176
1136
  }
1177
1137
  refreshAllSyncRoutes();
1178
1138
  },
@@ -1189,6 +1149,11 @@ export const initSharedWorker = (self) => async (run) => {
1189
1149
  })));
1190
1150
  const sharedWorkerRun = disposer.use(run.create({
1191
1151
  ...deps,
1152
+ getSyncTransportId: (key) => {
1153
+ const entry = transportsByKey.get(key);
1154
+ assertNonNullable(entry);
1155
+ return entry.id;
1156
+ },
1192
1157
  postConsoleEntryOrError,
1193
1158
  publishSyncState,
1194
1159
  refreshAllSyncRoutes,
@@ -1196,19 +1161,11 @@ export const initSharedWorker = (self) => async (run) => {
1196
1161
  tabLeaderPortStore,
1197
1162
  transports,
1198
1163
  }));
1199
- const tenantsByName = disposer.use(await sharedWorkerRun.ok(createSharedResourceByKey((message) => {
1200
- const memoryOnly = message.memoryOnly ||
1201
- platformDevicePersistence === "NotPersisted";
1202
- return createEvoluTenant({ ...message, memoryOnly }, memoryOnly ? "NotPersisted" : platformDevicePersistence, currentTenantsByName, workerId);
1203
- }, {
1164
+ const tenantsByName = disposer.use(await sharedWorkerRun.ok(createSharedResourceByKey((message) => createEvoluTenant(message, message.memoryOnly ? "NotPersisted" : platformDevicePersistence, currentTenantsByName, workerId), {
1204
1165
  idleDisposeAfter: "3s",
1205
1166
  lookup: (message) => message.name,
1206
1167
  })));
1207
1168
  sharedWorkerReady.resolve();
1208
- const forEachTenant = (callback) => {
1209
- for (const tenant of currentTenantsByName.values())
1210
- callback(tenant);
1211
- };
1212
1169
  return ok(disposer.move());
1213
1170
  }
1214
1171
  catch (e_1) {
@@ -1221,13 +1178,14 @@ export const initSharedWorker = (self) => async (run) => {
1221
1178
  await result_1;
1222
1179
  }
1223
1180
  };
1224
- const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, memoryOnly, }, devicePersistence, currentTenantsByName, workerId) => async (run) => {
1181
+ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, }, devicePersistence, currentTenantsByName, workerId) => async (run) => {
1225
1182
  const env_3 = { stack: [], error: void 0, hasError: false };
1226
1183
  try {
1227
1184
  const disposer = __addDisposableResource(env_3, new AsyncDisposableStack(), true);
1228
1185
  const tenantRun = disposer.use(run.create());
1229
1186
  const { deps } = run;
1230
1187
  const console = deps.console.child(name).child("SharedWorker");
1188
+ console.setLevel(consoleLevel);
1231
1189
  const instancesById = disposer.adopt(new Map(), async (instancesById) => {
1232
1190
  const env_4 = { stack: [], error: void 0, hasError: false };
1233
1191
  try {
@@ -1246,21 +1204,45 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1246
1204
  await result_4;
1247
1205
  }
1248
1206
  });
1249
- let dbWorkerPort = null;
1250
1207
  const dbWorkerInited = Promise.withResolvers();
1208
+ let dbWorker = { type: "Starting" };
1209
+ const queue = [];
1210
+ const pendingWriteCountByOwnerId = new Map();
1211
+ const pendingApplyCountForAllRoutesByOwnerId = new Map();
1212
+ // Queued applies of relay frames, counted per owner and source transport.
1213
+ const ownerTransportApplyRelation = createRefCountedRelation();
1214
+ // Each tab is told once during this tenant's lifetime, through its own
1215
+ // connection. Recreating the tenant after idle disposal retries startup
1216
+ // and may report the refusal again.
1217
+ const refusedTabPorts = new WeakSet();
1218
+ const routesByOwnerIdByKey = new Map();
1219
+ // Disposal does not wait for the DbWorker. It holds the database lock
1220
+ // until Dispose arrives, its tab closes, or this worker ends, and the next
1221
+ // DbWorker for this database, of this worker or another build, waits for
1222
+ // that lock before it reads the clock. A requested DbWorker that reports
1223
+ // in later gets Dispose, and a refused tenant stays refused, so it keeps
1224
+ // dropping requests.
1225
+ disposer.defer(() => {
1226
+ if (dbWorker.type === "Leading")
1227
+ dbWorker.port.postMessage({ type: "Dispose" });
1228
+ if (dbWorker.type !== "Refused")
1229
+ dbWorker = { type: "Disposed" };
1230
+ });
1251
1231
  const initDbWorker = () => {
1252
1232
  // Without a tab leader yet, the store subscription starts the DbWorker
1253
1233
  // once a tab announces itself.
1254
1234
  const tabLeaderPort = deps.tabLeaderPortStore.get();
1255
- if (startupError || !tabLeaderPort)
1235
+ if (dbWorker.type === "Refused" || !tabLeaderPort)
1256
1236
  return;
1257
1237
  const dbWorkerChannel = deps.createMessageChannel();
1258
1238
  const currentDbWorkerPort = dbWorkerChannel.port2;
1259
1239
  currentDbWorkerPort.onMessage = (message) => {
1260
1240
  switch (message.type) {
1261
1241
  case "LeaderAcquired": {
1262
- assertNotSame(dbWorkerPort, currentDbWorkerPort);
1263
- if (startupError || isDisposing) {
1242
+ const previous = dbWorker;
1243
+ if (previous.type === "Leading")
1244
+ assertNotSame(previous.port, currentDbWorkerPort);
1245
+ if (previous.type === "Refused" || previous.type === "Disposed") {
1264
1246
  // This worker was requested before the refusal or before
1265
1247
  // disposal started. The tenant will not use it, so let it
1266
1248
  // release the database lock.
@@ -1268,30 +1250,27 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1268
1250
  currentDbWorkerPort[Symbol.dispose]();
1269
1251
  break;
1270
1252
  }
1271
- const replacesLeader = dbWorkerPort !== null;
1272
- dbWorkerPort?.[Symbol.dispose]();
1273
- dbWorkerPort = currentDbWorkerPort;
1274
- activeDispatch = null;
1275
- // A replacement may advance the clock by releasing quarantine, or
1276
- // start behind it with an empty memoryOnly database. Keep the
1277
- // greater clock; pending writes retain their captured inputs.
1278
- if (sessionClock === null ||
1279
- orderTimestamp(sessionClock, message.clock) === -1) {
1280
- sessionClock = message.clock;
1281
- }
1253
+ if (previous.type === "Leading")
1254
+ previous.port[Symbol.dispose]();
1255
+ dbWorker = {
1256
+ type: "Leading",
1257
+ port: currentDbWorkerPort,
1258
+ // A replacement may advance the clock by releasing quarantine,
1259
+ // or start behind it with an empty memoryOnly database. Keep the
1260
+ // greater clock; pending writes retain their captured inputs.
1261
+ clock: previous.type !== "Leading" ||
1262
+ orderTimestamp(previous.clock, message.clock) === -1
1263
+ ? message.clock
1264
+ : previous.clock,
1265
+ activeDispatch: null,
1266
+ };
1282
1267
  // A replacement leader may have committed writes whose responses
1283
1268
  // were lost, and may have released quarantine at startup. A lost
1284
1269
  // response may also have reported stored owner messages, so the
1285
1270
  // owners reconcile through every transport again.
1286
- if (replacesLeader) {
1271
+ if (previous.type === "Leading") {
1287
1272
  refreshQueries();
1288
- const usedOwnerIds = new Set();
1289
- for (const instance of instancesById.values()) {
1290
- for (const { owner } of instance.ownerRegistrations.keys()) {
1291
- usedOwnerIds.add(owner.id);
1292
- }
1293
- }
1294
- requestCreateSyncMessages(usedOwnerIds, allTransports);
1273
+ requestCreateSyncMessages(new Set(getSyncOwners().map(({ ownerId }) => ownerId)), allTransports);
1295
1274
  }
1296
1275
  console.info("leaderAcquired");
1297
1276
  dbWorkerInited.resolve();
@@ -1299,21 +1278,20 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1299
1278
  break;
1300
1279
  }
1301
1280
  case "LeaderRefused": {
1302
- assertNotSame(dbWorkerPort, currentDbWorkerPort);
1303
1281
  // The worker refused startup and is releasing its resources.
1304
1282
  // Requests stay unanswered until their instances are disposed.
1305
1283
  // Keep the tenant unavailable, tell each connected tab once, and
1306
1284
  // tell tabs that connect later without starting another worker.
1307
- dbWorkerPort?.[Symbol.dispose]();
1308
- dbWorkerPort = null;
1285
+ if (dbWorker.type === "Leading") {
1286
+ assertNotSame(dbWorker.port, currentDbWorkerPort);
1287
+ dbWorker.port[Symbol.dispose]();
1288
+ }
1309
1289
  currentDbWorkerPort[Symbol.dispose]();
1310
- activeDispatch = null;
1311
1290
  queue.length = 0;
1312
1291
  pendingWriteCountByOwnerId.clear();
1313
1292
  pendingApplyCountForAllRoutesByOwnerId.clear();
1314
1293
  ownerTransportApplyRelation.clear();
1315
- startupError = message.error;
1316
- refreshSyncRoutes();
1294
+ dbWorker = { type: "Refused", error: message.error };
1317
1295
  deps.publishSyncState();
1318
1296
  console.info("leaderRefused", message.error);
1319
1297
  for (const instance of instancesById.values()) {
@@ -1323,19 +1301,18 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1323
1301
  break;
1324
1302
  }
1325
1303
  case "OnQueuedResponse": {
1326
- if (activeDispatch?.attemptId !== message.attemptId)
1304
+ const leading = dbWorker;
1305
+ if (leading.type !== "Leading" ||
1306
+ leading.activeDispatch?.attemptId !== message.attemptId)
1327
1307
  return;
1328
- const { entry } = activeDispatch;
1308
+ const { entry } = leading.activeDispatch;
1329
1309
  const { response } = message;
1330
- if (response.message.type === "Mutate" ||
1331
- response.message.type === "ApplySyncMessage") {
1332
- // Replays report their computed clock, which a replacement
1333
- // leader may have passed at startup. Keep the greater clock.
1334
- assertNonNullable(sessionClock);
1335
- if (orderTimestamp(sessionClock, response.message.clock) === -1) {
1336
- sessionClock = response.message.clock;
1337
- }
1338
- }
1310
+ // Replays report their computed clock, which a replacement leader
1311
+ // may have passed at startup. Keep the greater clock.
1312
+ if ((response.message.type === "Mutate" ||
1313
+ response.message.type === "ApplySyncMessage") &&
1314
+ orderTimestamp(leading.clock, response.message.clock) === -1)
1315
+ leading.clock = response.message.clock;
1339
1316
  switch (entry.type) {
1340
1317
  case "Read":
1341
1318
  case "Write":
@@ -1353,7 +1330,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1353
1330
  const head = queue.shift();
1354
1331
  assertNonNullable(head);
1355
1332
  updatePendingWork(head, -1);
1356
- activeDispatch = null;
1333
+ leading.activeDispatch = null;
1357
1334
  // Follow-up uploads and retries are already queued or sent. Only
1358
1335
  // now can removing the completed head establish convergence.
1359
1336
  refreshSyncRoutes();
@@ -1362,6 +1339,8 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1362
1339
  runQueue();
1363
1340
  break;
1364
1341
  }
1342
+ default:
1343
+ exhaustiveCheck(message);
1365
1344
  }
1366
1345
  };
1367
1346
  tabLeaderPort.postMessage({
@@ -1370,7 +1349,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1370
1349
  consoleLevel,
1371
1350
  sqliteSchema,
1372
1351
  encryptionKey,
1373
- memoryOnly,
1352
+ memoryOnly: devicePersistence === "NotPersisted",
1374
1353
  sharedWorkerId: workerId,
1375
1354
  port: dbWorkerChannel.port1.native,
1376
1355
  }, [dbWorkerChannel.port1.native]);
@@ -1382,11 +1361,6 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1382
1361
  instance.port.postMessage({ type: "RefreshQueries" });
1383
1362
  }
1384
1363
  };
1385
- const queue = [];
1386
- const pendingWriteCountByOwnerId = new Map();
1387
- const pendingApplyCountForAllRoutesByOwnerId = new Map();
1388
- // Queued applies of relay frames, counted per owner and source transport.
1389
- const ownerTransportApplyRelation = createRefCountedRelation();
1390
1364
  const updatePendingCount = (counts, key, delta) => {
1391
1365
  const count = (counts.get(key) ?? 0) + delta;
1392
1366
  assert(count >= 0, "Pending queue count must not become negative");
@@ -1430,59 +1404,42 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1430
1404
  updatePendingWork(entry, 1);
1431
1405
  queue.push(entry);
1432
1406
  };
1433
- let sessionClock = null;
1434
- let startupError = null;
1435
- let isDisposing = false;
1436
- // Each tab is told once during this tenant's lifetime, through its own
1437
- // connection. Recreating the tenant after idle disposal retries startup
1438
- // and may report the refusal again.
1439
- const refusedTabPorts = new WeakSet();
1440
1407
  const reportRefusal = (tabPort, error) => {
1441
1408
  if (refusedTabPorts.has(tabPort))
1442
1409
  return;
1443
1410
  refusedTabPorts.add(tabPort);
1444
1411
  tabPort.postMessage({ type: "Error", error });
1445
1412
  };
1446
- let activeDispatch = null;
1447
1413
  const runQueue = () => {
1448
- if (activeDispatch || !isNonEmptyArray(queue) || !dbWorkerPort)
1414
+ if (dbWorker.type !== "Leading" ||
1415
+ dbWorker.activeDispatch ||
1416
+ !isNonEmptyArray(queue))
1449
1417
  return;
1450
- assertNonNullable(sessionClock);
1451
1418
  const entry = firstInArray(queue);
1452
1419
  const attemptId = createId(run.deps);
1453
- activeDispatch = { entry, attemptId };
1420
+ dbWorker.activeDispatch = { entry, attemptId };
1454
1421
  if (entry.type === "Write" || entry.type === "ApplySyncMessage") {
1455
1422
  // A write captures its inputs on first dispatch, so a retry after
1456
1423
  // leader replacement reproduces the same timestamps.
1457
- entry.now ??= run.deps.time.now();
1458
- entry.clock ??= sessionClock;
1459
- dbWorkerPort.postMessage({
1424
+ entry.capturedInputs ??= {
1425
+ clock: dbWorker.clock,
1426
+ now: run.deps.time.now(),
1427
+ };
1428
+ dbWorker.port.postMessage({
1460
1429
  type: "Request",
1461
1430
  attemptId,
1462
1431
  request: entry.request,
1463
- clock: entry.clock,
1464
- now: entry.now,
1432
+ ...entry.capturedInputs,
1465
1433
  });
1466
1434
  }
1467
1435
  else {
1468
- dbWorkerPort.postMessage({
1436
+ dbWorker.port.postMessage({
1469
1437
  type: "Request",
1470
1438
  attemptId,
1471
1439
  request: entry.request,
1472
1440
  });
1473
1441
  }
1474
1442
  };
1475
- // Disposal does not wait for the DbWorker. It holds the database lock
1476
- // until Dispose arrives, its tab closes, or this worker ends, and the next
1477
- // DbWorker for this database, of this worker or another build, waits for
1478
- // that lock before it reads the clock. A requested DbWorker that reports
1479
- // in later gets Dispose from the isDisposing check.
1480
- disposer.defer(() => {
1481
- isDisposing = true;
1482
- dbWorkerPort?.postMessage({ type: "Dispose" });
1483
- dbWorkerPort = null;
1484
- activeDispatch = null;
1485
- });
1486
1443
  const handleResponseForEvolu = (response, first) => {
1487
1444
  // A disposed instance gets no patches, but its committed write still
1488
1445
  // synchronizes.
@@ -1491,13 +1448,11 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1491
1448
  case "Mutate":
1492
1449
  case "Query": {
1493
1450
  if (instance) {
1494
- const nextRowsByQuery = new Map(instance.rowsByQuery);
1495
1451
  const patchesByQuery = new Map();
1496
1452
  for (const [query, rows] of response.message.rowsByQuery) {
1497
- nextRowsByQuery.set(query, rows);
1498
1453
  patchesByQuery.set(query, makePatches(instance.rowsByQuery.get(query), rows));
1454
+ instance.rowsByQuery.set(query, rows);
1499
1455
  }
1500
- instance.rowsByQuery = nextRowsByQuery;
1501
1456
  instance.port.postMessage({
1502
1457
  type: "OnPatchesByQuery",
1503
1458
  patchesByQuery,
@@ -1561,30 +1516,19 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1561
1516
  exhaustiveCheck(response.message);
1562
1517
  }
1563
1518
  };
1564
- const routesByOwnerIdByKey = new Map();
1565
- const getRoute = (ownerId, key) => {
1566
- let routesByOwnerId = routesByOwnerIdByKey.get(key);
1567
- if (!routesByOwnerId) {
1568
- routesByOwnerId = new Map();
1569
- routesByOwnerIdByKey.set(key, routesByOwnerId);
1570
- }
1571
- let route = routesByOwnerId.get(ownerId);
1572
- if (!route) {
1573
- route = {
1574
- progress: {
1575
- type: "Pending",
1576
- roundRequired: true,
1577
- failure: null,
1578
- skip: null,
1579
- completeAt: null,
1580
- },
1581
- lastSentAt: null,
1582
- lastReceivedAt: null,
1583
- };
1584
- routesByOwnerId.set(ownerId, route);
1585
- }
1586
- return route;
1587
- };
1519
+ const getRoute = (ownerId, key) => routesByOwnerIdByKey
1520
+ .getOrInsertComputed(key, () => new Map())
1521
+ .getOrInsertComputed(ownerId, () => ({
1522
+ progress: {
1523
+ type: "Pending",
1524
+ roundRequired: true,
1525
+ failure: null,
1526
+ skip: null,
1527
+ completeAt: null,
1528
+ },
1529
+ lastSentAt: null,
1530
+ lastReceivedAt: null,
1531
+ }));
1588
1532
  const getSyncOwners = () => {
1589
1533
  const ownersById = new Map();
1590
1534
  for (const instance of instancesById.values()) {
@@ -1653,7 +1597,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1653
1597
  : pending.skip && !pending.skip.isRechecking
1654
1598
  ? {
1655
1599
  type: "Settled",
1656
- error: pending.skip.error,
1600
+ skippedError: pending.skip.error,
1657
1601
  completeAt: pending.completeAt,
1658
1602
  }
1659
1603
  : route.progress.type === "Complete"
@@ -1670,8 +1614,9 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1670
1614
  const { failedOwnerIds } = response.message;
1671
1615
  if (failedOwnerIds.size === 0)
1672
1616
  break;
1673
- // A retry would likely fail the same way, so the routes wait for an
1674
- // explicit request or a reopen.
1617
+ // A retry would likely fail the same way, so the routes wait for the
1618
+ // next round another event requests, such as an explicit request or
1619
+ // a reopen.
1675
1620
  const now = deps.time.now();
1676
1621
  for (const ownerId of failedOwnerIds) {
1677
1622
  deps.transports.forEachResourceForClaim(ownerId, (_, transport) => {
@@ -1754,9 +1699,9 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1754
1699
  };
1755
1700
  if (failure !== null) {
1756
1701
  // The first failure since the route settled requests one
1757
- // round. Further failures wait for an explicit request or a
1758
- // reopen, even after a converged reply, which may answer
1759
- // another request.
1702
+ // round. Further failures wait for the next round another event
1703
+ // requests, such as an explicit request or a reopen, even after
1704
+ // a converged reply, which may answer another request.
1760
1705
  const requestsRetry = route.progress.type !== "Pending" ||
1761
1706
  route.progress.failure === null;
1762
1707
  route.progress = failRoute(route.progress, errorToSyncRouteError(failure, now));
@@ -1903,7 +1848,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1903
1848
  return ownersById;
1904
1849
  };
1905
1850
  const requestCreateSyncMessages = (ownerIds, target, { afterQueuedWrites = true, } = {}) => {
1906
- if (startupError)
1851
+ if (dbWorker.type === "Refused")
1907
1852
  return;
1908
1853
  const usedOwnersById = getUsedOwnersById(ownerIds);
1909
1854
  // Opening, storing messages from another transport, a failure, and an
@@ -1941,8 +1886,11 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1941
1886
  const lastWriteIndex = afterQueuedWrites
1942
1887
  ? queue.findLastIndex(({ type }) => type === "Write" || type === "ApplySyncMessage")
1943
1888
  : -1;
1889
+ const dispatchedEntry = dbWorker.type === "Leading"
1890
+ ? dbWorker.activeDispatch?.entry
1891
+ : undefined;
1944
1892
  const isQueued = queue.some((entry, index) => index > lastWriteIndex &&
1945
- entry !== activeDispatch?.entry &&
1893
+ entry !== dispatchedEntry &&
1946
1894
  entry.type === "CreateSyncMessages" &&
1947
1895
  (entry.target.type === "AllTransports" ||
1948
1896
  (target.type === "Transport" && entry.target.key === target.key)) &&
@@ -1971,23 +1919,12 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
1971
1919
  const isFirstWritableUse = "writeKey" in syncOwner.owner &&
1972
1920
  !getUsedOwnersById(new Set([ownerId])).has(ownerId);
1973
1921
  // A transport first claimed for the owner starts every tenant's
1974
- // round through onFirstClaimAdded. A joining tenant also reconciles
1975
- // its existing history through the owner's already claimed transports.
1976
- // Later registrations reconcile only transports newly used here:
1977
- // earlier rounds may predate writes from an unregistered instance.
1978
- const claimedKeys = new Set(getClaimedKeys(ownerId));
1979
- const usedKeys = new Set();
1980
- for (const { ownerRegistrations } of instancesById.values()) {
1981
- for (const [usedSyncOwner, leases] of ownerRegistrations) {
1982
- if (usedSyncOwner.owner.id !== ownerId)
1983
- continue;
1984
- if (!leases.some((lease) => lease !== null))
1985
- continue;
1986
- for (const transport of usedSyncOwner.transports) {
1987
- usedKeys.add(structuralLookup(transport));
1988
- }
1989
- }
1990
- }
1922
+ // round through onFirstClaimAdded. A tenant's first writable
1923
+ // registration reconciles its existing history through the
1924
+ // transports already claimed for the owner. Later writes upload
1925
+ // through every claimed transport, whichever instance made them, so
1926
+ // later registrations need no round.
1927
+ const claimedKeys = getClaimedKeys(ownerId);
1991
1928
  const leases = instance.ownerRegistrations.getOrInsertComputed(syncOwner, () => []);
1992
1929
  // First-claim callbacks must see the owner before acquisition finishes.
1993
1930
  const index = leases.push(null) - 1;
@@ -2003,17 +1940,12 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2003
1940
  }
2004
1941
  deps.refreshAllSyncRoutes();
2005
1942
  }
2006
- const keysToSync = isFirstWritableUse
2007
- ? claimedKeys
2008
- : syncOwner.transports
2009
- .map(structuralLookup)
2010
- .filter((key) => claimedKeys.has(key) && !usedKeys.has(key));
2011
- for (const key of keysToSync) {
2012
- requestCreateSyncMessages(new Set([ownerId]), {
2013
- type: "Transport",
2014
- key,
2015
- });
2016
- }
1943
+ if (isFirstWritableUse)
1944
+ for (const key of claimedKeys)
1945
+ requestCreateSyncMessages(new Set([ownerId]), {
1946
+ type: "Transport",
1947
+ key,
1948
+ });
2017
1949
  }
2018
1950
  else {
2019
1951
  const claimLeases = instance.ownerRegistrations.get(syncOwner);
@@ -2045,8 +1977,8 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2045
1977
  deps.publishSyncState();
2046
1978
  });
2047
1979
  const tenant = disposable({
2048
- getSyncTenant: () => startupError
2049
- ? { type: "Refused", name, error: startupError }
1980
+ getSyncTenant: () => dbWorker.type === "Refused"
1981
+ ? { type: "Refused", name, error: dbWorker.error }
2050
1982
  : {
2051
1983
  type: "Active",
2052
1984
  name,
@@ -2062,10 +1994,46 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2062
1994
  // before yielding. Snapshot reads must not create
2063
1995
  // missing routes.
2064
1996
  assertNotUndefined(route);
2065
- return { transportKey: key, ...route };
1997
+ const transportId = deps.getSyncTransportId(key);
1998
+ const { progress, lastSentAt, lastReceivedAt } = route;
1999
+ if (progress.type === "Pending")
2000
+ return {
2001
+ type: "Pending",
2002
+ transportId,
2003
+ failure: progress.failure,
2004
+ skippedError: progress.skip?.error ?? null,
2005
+ completeAt: progress.completeAt,
2006
+ lastSentAt,
2007
+ lastReceivedAt,
2008
+ };
2009
+ // Only a sent round settles a route or completes
2010
+ // it.
2011
+ assertNonNullable(lastSentAt);
2012
+ if (progress.type === "Complete")
2013
+ return {
2014
+ type: "Complete",
2015
+ transportId,
2016
+ completeAt: progress.completeAt,
2017
+ lastSentAt,
2018
+ lastReceivedAt,
2019
+ };
2020
+ // Only a received reply records a skip.
2021
+ assertNonNullable(lastReceivedAt);
2022
+ return {
2023
+ type: "Settled",
2024
+ transportId,
2025
+ skippedError: progress.skippedError,
2026
+ completeAt: progress.completeAt,
2027
+ lastSentAt,
2028
+ lastReceivedAt,
2029
+ };
2066
2030
  }),
2067
2031
  }
2068
- : { type: "Readonly", ownerId, transportKeys }),
2032
+ : {
2033
+ type: "Readonly",
2034
+ ownerId,
2035
+ transportIds: transportKeys.map(deps.getSyncTransportId),
2036
+ }),
2069
2037
  },
2070
2038
  refreshSyncRoutes,
2071
2039
  addInstance: (message, tabPort, onDisposed) => {
@@ -2082,13 +2050,11 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2082
2050
  }),
2083
2051
  port: deps.createMessagePort(message.evoluPort),
2084
2052
  tabPort,
2085
- onDisposed,
2086
2053
  rowsByQuery: new Map(),
2087
- useOwnerMutex: createMutex(),
2088
2054
  [Symbol.asyncDispose]: () => disposer.disposeAsync(),
2089
2055
  };
2090
2056
  instancesById.set(instance.id, instance);
2091
- disposer.defer(instance.onDisposed);
2057
+ disposer.defer(onDisposed);
2092
2058
  disposer.defer(() => {
2093
2059
  for (const leases of instance.ownerRegistrations.values()) {
2094
2060
  for (const lease of leases)
@@ -2103,6 +2069,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2103
2069
  // aborts queued UseOwner batches and waits for the running one, whose
2104
2070
  // transport claim cannot be aborted, so the release sees every lease.
2105
2071
  const instanceRun = disposer.use(tenantRun.create());
2072
+ const useOwnerMutex = createMutex();
2106
2073
  disposer.defer(() => {
2107
2074
  instancesById.delete(instance.id);
2108
2075
  refreshSyncRoutes();
@@ -2131,7 +2098,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2131
2098
  return instance[Symbol.asyncDispose]();
2132
2099
  });
2133
2100
  instance.port.onMessage = (message) => {
2134
- if (startupError)
2101
+ if (dbWorker.type === "Refused")
2135
2102
  return;
2136
2103
  switch (message.type) {
2137
2104
  case "Query":
@@ -2163,7 +2130,7 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2163
2130
  break;
2164
2131
  }
2165
2132
  case "UseOwner": {
2166
- void instanceRun(instance.useOwnerMutex.withLock(async (run) => {
2133
+ void instanceRun(useOwnerMutex.withLock(async (run) => {
2167
2134
  for (const action of message.actions) {
2168
2135
  switch (action.action) {
2169
2136
  case "sync":
@@ -2191,14 +2158,16 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2191
2158
  }));
2192
2159
  break;
2193
2160
  }
2161
+ default:
2162
+ exhaustiveCheck(message);
2194
2163
  }
2195
2164
  };
2196
- if (startupError)
2197
- reportRefusal(instance.tabPort, startupError);
2165
+ if (dbWorker.type === "Refused")
2166
+ reportRefusal(instance.tabPort, dbWorker.error);
2198
2167
  },
2199
2168
  requestCreateSyncMessages,
2200
2169
  requestApplySyncMessage: (ownerId, inputMessage, source) => {
2201
- if (startupError)
2170
+ if (dbWorker.type === "Refused")
2202
2171
  return;
2203
2172
  const owner = getUsedOwnersById(new Set([ownerId])).get(ownerId);
2204
2173
  if (!owner)
@@ -2236,39 +2205,16 @@ const createEvoluTenant = ({ name, consoleLevel, sqliteSchema, encryptionKey, me
2236
2205
  await result_3;
2237
2206
  }
2238
2207
  };
2239
- // | (Typed<"reset"> & {
2240
- // readonly onCompleteId: CallbackId;
2241
- // readonly reload: boolean;
2242
- // readonly restore?: {
2243
- // readonly sqliteSchema: SqliteSchema;
2244
- // readonly mnemonic: Mnemonic;
2245
- // };
2246
- // })
2247
- // | (Typed<"ensureSqliteSchema"> & {
2248
- // readonly sqliteSchema: SqliteSchema;
2249
- // })
2250
- // | (Typed<"export"> & {
2251
- // readonly onCompleteId: CallbackId;
2252
- // })
2253
- // | (Typed<"useOwner"> & {
2254
- // readonly use: boolean;
2255
- // readonly owner: SyncOwner;
2256
- // });
2257
- // | (Typed<"onReset"> & {
2258
- // readonly onCompleteId: CallbackId;
2259
- // readonly reload: boolean;
2260
- // })
2261
2208
  // TODO: SharedWorker follow-ups.
2262
2209
  // - Rotate the node ID when a copied database is detected; see the Duplicate
2263
2210
  // node IDs section in the Timestamp module.
2264
2211
  // - Detect DbWorker and port liveness so a worker-only crash resumes the queue.
2265
2212
  // Defer panicked-worker restart until failure detection and recovery are
2266
- // defined, accounting for SQLite WASM's detection limits. A mutation that
2267
- // throws is answered, but other SQLite operations are expected not to throw.
2213
+ // defined, accounting for SQLite WASM's detection limits. Mutations, round
2214
+ // creation, and frame application are answered when SQLite throws; a query
2215
+ // or an export that throws is not, and stops the database's queue.
2268
2216
  // - Split worker protocol types and the EvoluTenant implementation into focused
2269
2217
  // modules.
2270
- // - Remove the obsolete commented protocol block above.
2271
- // - Propagate invalid protocol messages to sync state.
2272
2218
  // - Bound sync state publishing during a bulk catch-up: every sent and applied
2273
2219
  // frame changes a route timestamp, so each frame broadcasts a full snapshot
2274
2220
  // to every tab. Throttling needs a wall-clock policy and a deterministic way