@evolu/common 8.10.0 → 8.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/dist/src/Config.d.ts +22 -22
  2. package/dist/src/Config.d.ts.map +1 -1
  3. package/dist/src/Console.d.ts +62 -7
  4. package/dist/src/Console.d.ts.map +1 -1
  5. package/dist/src/Console.js +20 -4
  6. package/dist/src/Crypto.d.ts +76 -4
  7. package/dist/src/Crypto.d.ts.map +1 -1
  8. package/dist/src/Crypto.js +55 -4
  9. package/dist/src/Error.d.ts +45 -0
  10. package/dist/src/Error.d.ts.map +1 -1
  11. package/dist/src/Error.js +69 -0
  12. package/dist/src/Fs.d.ts +92 -18
  13. package/dist/src/Fs.d.ts.map +1 -1
  14. package/dist/src/Fs.js +2 -0
  15. package/dist/src/Identicon.d.ts +2 -2
  16. package/dist/src/Identicon.js +2 -2
  17. package/dist/src/LeakDetector.d.ts +22 -3
  18. package/dist/src/LeakDetector.d.ts.map +1 -1
  19. package/dist/src/LeakDetector.js +12 -2
  20. package/dist/src/LockManager.d.ts +8 -0
  21. package/dist/src/LockManager.d.ts.map +1 -1
  22. package/dist/src/LockManager.js +6 -0
  23. package/dist/src/Object.d.ts.map +1 -1
  24. package/dist/src/Object.js +5 -0
  25. package/dist/src/Platform.d.ts +47 -7
  26. package/dist/src/Platform.d.ts.map +1 -1
  27. package/dist/src/Platform.js +24 -5
  28. package/dist/src/Random.d.ts +25 -2
  29. package/dist/src/Random.d.ts.map +1 -1
  30. package/dist/src/Random.js +14 -2
  31. package/dist/src/Resource.d.ts +156 -1
  32. package/dist/src/Resource.d.ts.map +1 -1
  33. package/dist/src/Resource.js +201 -72
  34. package/dist/src/Schedule.d.ts +11 -10
  35. package/dist/src/Schedule.d.ts.map +1 -1
  36. package/dist/src/Schedule.js +1 -1
  37. package/dist/src/Sqlite.d.ts +132 -16
  38. package/dist/src/Sqlite.d.ts.map +1 -1
  39. package/dist/src/Sqlite.js +63 -9
  40. package/dist/src/Task.d.ts +15 -4
  41. package/dist/src/Task.d.ts.map +1 -1
  42. package/dist/src/Task.js +41 -15
  43. package/dist/src/Test.d.ts +9 -0
  44. package/dist/src/Test.d.ts.map +1 -1
  45. package/dist/src/Test.js +4 -0
  46. package/dist/src/Time.d.ts +106 -9
  47. package/dist/src/Time.d.ts.map +1 -1
  48. package/dist/src/Time.js +55 -4
  49. package/dist/src/Type.d.ts +1455 -1310
  50. package/dist/src/Type.d.ts.map +1 -1
  51. package/dist/src/Type.js +1274 -517
  52. package/dist/src/WebSocket.d.ts +164 -13
  53. package/dist/src/WebSocket.d.ts.map +1 -1
  54. package/dist/src/WebSocket.js +133 -24
  55. package/dist/src/Worker.d.ts +90 -8
  56. package/dist/src/Worker.d.ts.map +1 -1
  57. package/dist/src/Worker.js +28 -2
  58. package/dist/src/index.d.ts +6 -7
  59. package/dist/src/index.d.ts.map +1 -1
  60. package/dist/src/index.js +2 -3
  61. package/dist/src/local-first/Db.d.ts +52 -3
  62. package/dist/src/local-first/Db.d.ts.map +1 -1
  63. package/dist/src/local-first/Db.js +412 -137
  64. package/dist/src/local-first/Evolu.d.ts +336 -211
  65. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  66. package/dist/src/local-first/Evolu.js +102 -15
  67. package/dist/src/local-first/Owner.d.ts +13 -30
  68. package/dist/src/local-first/Owner.d.ts.map +1 -1
  69. package/dist/src/local-first/Owner.js +13 -30
  70. package/dist/src/local-first/Protocol.d.ts +94 -16
  71. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  72. package/dist/src/local-first/Protocol.js +118 -38
  73. package/dist/src/local-first/Query.d.ts +8 -15
  74. package/dist/src/local-first/Query.d.ts.map +1 -1
  75. package/dist/src/local-first/Schema.d.ts +335 -21
  76. package/dist/src/local-first/Schema.d.ts.map +1 -1
  77. package/dist/src/local-first/Schema.js +214 -17
  78. package/dist/src/local-first/Shared.d.ts +537 -22
  79. package/dist/src/local-first/Shared.d.ts.map +1 -1
  80. package/dist/src/local-first/Shared.js +1437 -234
  81. package/dist/src/local-first/Storage.d.ts +192 -14
  82. package/dist/src/local-first/Storage.d.ts.map +1 -1
  83. package/dist/src/local-first/Storage.js +81 -20
  84. package/dist/src/local-first/Timestamp.d.ts +392 -41
  85. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  86. package/dist/src/local-first/Timestamp.js +403 -81
  87. package/dist/src/local-first/index.d.ts +0 -1
  88. package/dist/src/local-first/index.d.ts.map +1 -1
  89. package/dist/src/local-first/index.js +0 -1
  90. package/package.json +1 -1
  91. package/src/Assert.test.ts +2 -5
  92. package/src/Config.test.ts +2 -6
  93. package/src/Config.ts +133 -133
  94. package/src/Console.ts +62 -7
  95. package/src/Crypto.ts +76 -4
  96. package/src/Eq.test.ts +2 -3
  97. package/src/Error.test.ts +76 -3
  98. package/src/Error.ts +71 -0
  99. package/src/Fs.ts +92 -18
  100. package/src/Identicon.ts +2 -2
  101. package/src/LeakDetector.ts +22 -3
  102. package/src/LockManager.ts +8 -0
  103. package/src/Object.test.ts +27 -12
  104. package/src/Object.ts +5 -0
  105. package/src/Platform.ts +50 -8
  106. package/src/Random.ts +25 -2
  107. package/src/Resource.test.ts +837 -0
  108. package/src/Resource.ts +235 -15
  109. package/src/Schedule.test.ts +50 -12
  110. package/src/Schedule.ts +24 -14
  111. package/src/Sqlite.ts +137 -17
  112. package/src/Task.test.ts +189 -8
  113. package/src/Task.ts +56 -17
  114. package/src/Test.ts +9 -0
  115. package/src/Time.ts +106 -9
  116. package/src/Type.test.ts +946 -1028
  117. package/src/Type.ts +4195 -3136
  118. package/src/Types.test.ts +4 -14
  119. package/src/WebSocket.ts +313 -40
  120. package/src/Worker.ts +90 -8
  121. package/src/index.ts +15 -6
  122. package/src/local-first/Db.ts +644 -339
  123. package/src/local-first/Evolu.test.ts +686 -21
  124. package/src/local-first/Evolu.ts +450 -228
  125. package/src/local-first/Owner.ts +13 -30
  126. package/src/local-first/Protocol.test.ts +617 -10
  127. package/src/local-first/Protocol.ts +196 -72
  128. package/src/local-first/Query.ts +8 -15
  129. package/src/local-first/Schema.test.ts +143 -0
  130. package/src/local-first/Schema.ts +363 -24
  131. package/src/local-first/Shared.test.ts +7731 -559
  132. package/src/local-first/Shared.ts +2036 -267
  133. package/src/local-first/Storage.ts +218 -32
  134. package/src/local-first/Timestamp.test.ts +344 -70
  135. package/src/local-first/Timestamp.ts +434 -118
  136. package/src/local-first/index.ts +0 -1
  137. package/dist/src/local-first/Error.d.ts +0 -12
  138. package/dist/src/local-first/Error.d.ts.map +0 -1
  139. package/dist/src/local-first/Error.js +0 -6
  140. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  141. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  142. package/dist/src/local-first/LocalAuth.js +0 -179
  143. package/src/local-first/Error.ts +0 -17
  144. package/src/local-first/LocalAuth.ts +0 -457
@@ -1,6 +1,39 @@
1
1
  /**
2
2
  * Platform-agnostic Evolu DbWorker.
3
3
  *
4
+ * ### Database version
5
+ *
6
+ * Every database records `dbVersion` in `evolu_version`. The version describes
7
+ * Evolu's internal persisted format: the layout of its system tables and the
8
+ * meaning of the data stored in them. It is independent of the application
9
+ * schema, which evolves append-only through {@link ensureSqliteSchema}, and of
10
+ * the network protocol version, which is checked per message. Many Evolu
11
+ * releases can share one database version.
12
+ *
13
+ * Bump `dbVersion` for any change that older code would misread, not only for
14
+ * changed SQL. A new quarantine reason, for example, changes what startup may
15
+ * replay even when its columns are additive. Each bump ships with a migration
16
+ * from the previous version; fresh databases are created at the latest layout
17
+ * directly.
18
+ *
19
+ * Startup holds the database leader lock, then checks the stored version record
20
+ * before it reads the clock, ensures the application schema, or replays
21
+ * quarantine. Databases written before the version record existed hold one
22
+ * `protocolVersion` row instead; that known legacy layout is converted to
23
+ * version 1. A newer stored version refuses startup with
24
+ * {@link UnsupportedDbVersionError}. The refusal returns from the startup
25
+ * transaction before anything is written and is posted to the SharedWorker; the
26
+ * worker then exits and releases its resources. The single version row is
27
+ * created in the same transaction as the other system tables.
28
+ *
29
+ * Code released before the version record existed never reads it. It recognizes
30
+ * an initialized database by the `evolu_version` table alone, so it opens a
31
+ * newer database and replays all quarantine regardless of reason, applying
32
+ * drift quarantine at once without advancing the clock. Renaming the table
33
+ * would make such code fail at startup instead. That was declined: the replay
34
+ * needs drift quarantine and an earlier release on the same device at once, and
35
+ * failing would break a rollback to an earlier release outright.
36
+ *
4
37
  * @module
5
38
  */
6
39
 
@@ -11,21 +44,18 @@ import {
11
44
  type NonEmptyReadonlyArray,
12
45
  } from "../Array.ts";
13
46
  import {
47
+ assert,
14
48
  assertNonEmptyReadonlyArray,
15
49
  assertNonNullable,
16
50
  assertNotUndefined,
17
51
  } from "../Assert.ts";
18
52
  import type { ConsoleLevel } from "../Console.ts";
19
- import {
20
- EncryptionKey,
21
- type DecryptWithXChaCha20Poly1305Error,
22
- type RandomBytesDep,
23
- } from "../Crypto.ts";
53
+ import { EncryptionKey, type RandomBytesDep } from "../Crypto.ts";
24
54
  import { constFalse, constVoid } from "../Function.ts";
25
55
  import type { LockManagerDep } from "../LockManager.ts";
26
56
  import { acquireLeaderLock } from "../LockManager.ts";
27
57
  import { createMutableRecord, getOwnProp, objectToEntries } from "../Object.ts";
28
- import { ok, type Result } from "../Result.ts";
58
+ import { err, getOk, ok, type Result } from "../Result.ts";
29
59
  import type {
30
60
  CreateSqliteDriverDep,
31
61
  SqliteDep,
@@ -42,7 +72,12 @@ import {
42
72
  SqliteValue,
43
73
  } from "../Sqlite.ts";
44
74
  import { callback, type Run, type Task } from "../Task.ts";
45
- import { Millis, millisToDateIso, type TimeDep } from "../Time.ts";
75
+ import {
76
+ millisToDateIso,
77
+ saturateMillis,
78
+ type Millis,
79
+ type TimeDep,
80
+ } from "../Time.ts";
46
81
  import {
47
82
  assertType,
48
83
  type FiniteNumber,
@@ -50,9 +85,12 @@ import {
50
85
  IdBytes,
51
86
  idBytesToId,
52
87
  idToIdBytes,
88
+ NonNaNNumber,
53
89
  onePositiveInt,
90
+ PositiveInt,
54
91
  type ExtractTyped,
55
92
  type Name,
93
+ type Typed,
56
94
  } from "../Type.ts";
57
95
  import type {
58
96
  CreateBroadcastChannelDep,
@@ -68,17 +106,16 @@ import {
68
106
  createProtocolMessageForSync,
69
107
  decryptAndDecodeDbChange,
70
108
  encodeAndEncryptDbChange,
71
- protocolVersion,
72
109
  SubscriptionFlags,
73
- type ProtocolInvalidDataError,
74
110
  type ProtocolMessage,
75
- type ProtocolTimestampMismatchError,
76
111
  } from "./Protocol.ts";
77
112
  import type { Query, RowsByQueryMap } from "./Query.ts";
78
113
  import type { MutationChange, SqliteSchemaDep } from "./Schema.ts";
79
114
  import {
80
115
  ensureSqliteSchema,
81
- getEvoluSqliteSchema,
116
+ isLocalOnlyTable,
117
+ QuarantineOrigin,
118
+ QuarantineReason,
82
119
  systemColumns,
83
120
  } from "./Schema.ts";
84
121
  import type {
@@ -86,7 +123,7 @@ import type {
86
123
  DbWorkerInput,
87
124
  DbWorkerOutput,
88
125
  DbWorkerQueuedResponse,
89
- DbWorkerRequest,
126
+ EvoluInput,
90
127
  } from "./Shared.ts";
91
128
  import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
92
129
  import {
@@ -101,15 +138,13 @@ import {
101
138
  type CrdtMessage,
102
139
  type Storage,
103
140
  } from "./Storage.ts";
104
- import type {
105
- Timestamp,
106
- TimestampCounterOverflowError,
107
- TimestampDriftError,
108
- TimestampTimeOutOfRangeError,
109
- } from "./Timestamp.ts";
141
+ import type { Timestamp } from "./Timestamp.ts";
110
142
  import {
111
143
  createInitialTimestamp,
112
144
  defaultTimestampMaxDrift,
145
+ isTimestampBeyondMaxDrift,
146
+ maxCounter,
147
+ maxNodeId,
113
148
  receiveTimestamp,
114
149
  sendTimestamp,
115
150
  TimestampBytes,
@@ -141,9 +176,29 @@ export type DbWorkerDeps = WorkerDeps &
141
176
  LockManagerDep &
142
177
  CreateSqliteDriverDep;
143
178
 
179
+ /** The database version this code creates and supports; see the module doc. */
180
+ const dbVersion = PositiveInt.orThrow(2);
181
+
182
+ /**
183
+ * The stored database version is newer than this code supports. Newer code
184
+ * created or migrated the database, which is left unchanged. This happens when
185
+ * older code opens a database a newer build migrated, for example an older
186
+ * build loaded from a cache, or when the app was downgraded after a newer
187
+ * version migrated the local data. On the web, a refused tab reloads once for
188
+ * each stored version, so it loads the build the server now serves, and the
189
+ * error is reported when that build refuses too. Then close all tabs of the
190
+ * app, which lets a service worker replace a cached older build, or update the
191
+ * app to a version that supports `storedVersion`.
192
+ */
193
+ export interface UnsupportedDbVersionError extends Typed<"UnsupportedDbVersionError"> {
194
+ readonly storedVersion: PositiveInt;
195
+ readonly supportedVersion: PositiveInt;
196
+ }
197
+
144
198
  /**
145
- * Starts the platform-agnostic Evolu DbWorker and owns its resources until the
146
- * worker receives a dispose message or its {@link Run} is aborted.
199
+ * Starts the platform-agnostic Evolu DbWorker and owns its resources until
200
+ * startup is refused, the worker receives a dispose message, or its {@link Run}
201
+ * is aborted.
147
202
  */
148
203
  export const startDbWorker =
149
204
  (self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
@@ -199,27 +254,60 @@ export const startDbWorker =
199
254
  baseSqliteStorage,
200
255
  timestampConfig: { maxDrift: defaultTimestampMaxDrift },
201
256
  };
202
- const currentSchema = getEvoluSqliteSchema(dbDeps)();
203
- const dbIsInitialized = "evolu_version" in currentSchema.tables;
204
- const clock = createClock(dbDeps)(dbIsInitialized);
205
-
206
- sqlite.transaction(() => {
207
- if (!dbIsInitialized) initializeDb(dbDeps)(clock.get());
208
- ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema, currentSchema);
209
- tryApplyQuarantinedMessages(dbDeps);
210
- });
211
-
212
- const storage = createClientStorage({ ...dbDeps, clock })({
213
- onError: (error) => {
214
- consoleEntryOrErrorBroadcastChannel.postMessage({
215
- type: "Error",
216
- error,
217
- });
257
+ const startup = sqlite.transaction(
258
+ (): Result<Timestamp, UnsupportedDbVersionError> => {
259
+ // Only the version record is read before the version check, because a
260
+ // newer database can hold schema objects this code cannot read.
261
+ const { rows: versionColumnRows } = sqlite.exec<{ name: string }>(sql`
262
+ select "name" from pragma_table_info('evolu_version');
263
+ `);
264
+ const versionColumns = new Set(
265
+ versionColumnRows.map((row) => row.name),
266
+ );
267
+ let initialClock: Timestamp;
268
+ if (versionColumns.size === 0) {
269
+ initialClock = createInitialTimestamp(dbDeps);
270
+ initializeDb(dbDeps)(initialClock);
271
+ } else {
272
+ const version = ensureDbVersion(dbDeps)(versionColumns);
273
+ if (!version.ok) return version;
274
+ const { rows } = sqlite.exec<{ clock: TimestampBytes }>(sql`
275
+ select "clock" from evolu_config limit 1;
276
+ `);
277
+ assertNonEmptyReadonlyArray(rows);
278
+ initialClock = timestampBytesToTimestamp(firstInArray(rows).clock);
279
+ }
280
+ ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema);
281
+ const released = releaseDriftQuarantine(dbDeps)(initialClock);
282
+ if (released) {
283
+ initialClock = released;
284
+ saveClock(dbDeps)(released);
285
+ }
286
+ tryApplyQuarantinedMessages(dbDeps);
287
+ return ok(initialClock);
218
288
  },
219
- });
289
+ );
290
+ if (!startup.ok) {
291
+ // Nothing was written. Returning lets the disposer close SQLite and
292
+ // release the database lock, which the SharedWorker's tenant disposal
293
+ // waits on. The tab leader lock is unaffected.
294
+ port.postMessage({
295
+ type: "LeaderRefused",
296
+ name: initMessage.name,
297
+ error: startup.error,
298
+ });
299
+ return ok();
300
+ }
301
+ const initialClock = startup.value;
302
+
303
+ const storage = createClientStorage(dbDeps);
220
304
  const dbWorkerRun = disposer.use(run.create({ storage }));
221
305
 
222
- port.postMessage({ type: "LeaderAcquired", name: initMessage.name });
306
+ port.postMessage({
307
+ type: "LeaderAcquired",
308
+ name: initMessage.name,
309
+ clock: initialClock,
310
+ });
223
311
 
224
312
  await run.ok(
225
313
  callback<void>(({ resolve }) => {
@@ -229,14 +317,14 @@ export const startDbWorker =
229
317
  return;
230
318
  }
231
319
 
232
- const { callbackId, request } = input;
320
+ const { attemptId } = input;
233
321
  const postQueuedResponse = (
234
322
  response: DbWorkerQueuedResponse,
235
323
  ): void => {
236
324
  port.postMessage(
237
325
  {
238
326
  type: "OnQueuedResponse",
239
- callbackId,
327
+ attemptId,
240
328
  response,
241
329
  },
242
330
  response.type === "ForEvolu" && response.message.type === "Export"
@@ -245,48 +333,77 @@ export const startDbWorker =
245
333
  );
246
334
  };
247
335
 
248
- if (request.type === "ForSharedWorker") {
249
- if (request.message.type === "ApplySyncMessage") {
336
+ if ("clock" in input) {
337
+ const { clock: inputClock, now } = input;
338
+ let committedClock = inputClock;
339
+ const context: WriteContext = {
340
+ now,
341
+ clock: {
342
+ get: () => committedClock,
343
+ set: (timestamp) => {
344
+ committedClock = timestamp;
345
+ },
346
+ },
347
+ };
348
+ const request = input.request;
349
+ if (request.type === "ForSharedWorker") {
250
350
  const { owner, inputMessage } = request.message;
251
-
252
351
  void dbWorkerRun(async (run) => {
253
- storage.setRequestContext(owner.encryptionKey);
254
-
352
+ storage.setRequestContext(owner.encryptionKey, context);
255
353
  const result = await run.abortable(
256
354
  applyProtocolMessageAsClient(inputMessage, {
257
355
  writeKey: owner.writeKey,
258
356
  }),
259
357
  );
260
-
261
358
  postQueuedResponse({
262
359
  type: "ForSharedWorker",
263
360
  message: {
264
361
  type: "ApplySyncMessage",
362
+ clock: context.clock.get(),
265
363
  ownerId: owner.id,
266
364
  didWriteMessages: storage.didWriteMessages(),
267
365
  result,
268
366
  },
269
367
  });
270
-
271
368
  return ok();
272
369
  });
273
- return;
370
+ } else {
371
+ postQueuedResponse({
372
+ type: "ForEvolu",
373
+ id: request.id,
374
+ message: handleMutation({
375
+ ...dbDeps,
376
+ clock: context.clock,
377
+ })(request.message, now),
378
+ });
274
379
  }
380
+ return;
381
+ }
275
382
 
383
+ const request = input.request;
384
+ if (request.type === "ForSharedWorker") {
276
385
  const protocolMessagesByOwnerId = new Map<
277
386
  OwnerId,
278
387
  ProtocolMessage
279
388
  >();
389
+ const failedOwnerIds = new Set<OwnerId>();
280
390
 
391
+ // An unanswered attempt would block the tenant queue, so a failed
392
+ // owner is logged and reported instead of thrown.
281
393
  for (const owner of request.message.owners) {
282
394
  storage.setRequestContext(owner.encryptionKey);
283
- protocolMessagesByOwnerId.set(
284
- owner.id,
285
- createProtocolMessageForSync({
286
- storage,
287
- console: deps.console,
288
- })(owner.id, SubscriptionFlags.Subscribe),
289
- );
395
+ try {
396
+ protocolMessagesByOwnerId.set(
397
+ owner.id,
398
+ createProtocolMessageForSync({ storage })(
399
+ owner.id,
400
+ SubscriptionFlags.Subscribe,
401
+ ),
402
+ );
403
+ } catch (error) {
404
+ deps.console.error(error);
405
+ failedOwnerIds.add(owner.id);
406
+ }
290
407
  }
291
408
 
292
409
  postQueuedResponse({
@@ -294,6 +411,7 @@ export const startDbWorker =
294
411
  message: {
295
412
  type: "CreateSyncMessages",
296
413
  protocolMessagesByOwnerId,
414
+ failedOwnerIds,
297
415
  },
298
416
  });
299
417
  return;
@@ -320,23 +438,7 @@ export const startDbWorker =
320
438
  file: sqlite.export(),
321
439
  },
322
440
  });
323
- return;
324
441
  }
325
-
326
- const result = handleMutation({ ...dbDeps, clock })(request.message);
327
- if (!result.ok) {
328
- consoleEntryOrErrorBroadcastChannel.postMessage({
329
- type: "Error",
330
- error: result.error,
331
- });
332
- return;
333
- }
334
-
335
- postQueuedResponse({
336
- type: "ForEvolu",
337
- id: request.id,
338
- message: result.value,
339
- });
340
442
  };
341
443
 
342
444
  return () => {
@@ -348,64 +450,135 @@ export const startDbWorker =
348
450
  return ok();
349
451
  };
350
452
 
351
- /**
352
- * Hybrid Logical Clock. Keeps the current timestamp in memory to avoid frequent
353
- * SQLite reads.
354
- */
453
+ /** Clock state owned by one write request; published only after commit. */
355
454
  interface Clock {
356
455
  readonly get: () => Timestamp;
357
- readonly save: (timestamp: Timestamp) => void;
456
+ readonly set: (timestamp: Timestamp) => void;
358
457
  }
359
458
 
360
459
  interface ClockDep {
361
460
  readonly clock: Clock;
362
461
  }
363
462
 
364
- const createClock =
365
- (deps: RandomBytesDep & SqliteDep) =>
366
- (dbIsInitialized: boolean): Clock => {
367
- let currentTimestamp: Timestamp;
463
+ interface WriteContext extends ClockDep {
464
+ readonly now: Millis;
465
+ }
368
466
 
369
- if (dbIsInitialized) {
370
- const { rows } = deps.sqlite.exec<{ clock: TimestampBytes }>(sql`
371
- select clock
372
- from evolu_config
373
- limit 1;
467
+ /**
468
+ * Persists `timestamp` only when it is greater than the stored clock.
469
+ *
470
+ * Requests report their computed clock, which can be older than the stored
471
+ * clock when replayed after startup release. The SQL guard keeps the stored
472
+ * clock from moving backwards; the SharedWorker adopts response clocks only
473
+ * when newer than its session clock.
474
+ *
475
+ * Timestamp bytes sort like their timestamps and SQLite compares blobs byte by
476
+ * byte, so persistence takes one statement even when the clock does not
477
+ * advance.
478
+ *
479
+ * Local-only mutations and sync requests that do not invoke `writeMessages`
480
+ * skip this. Successfully processed batches in `writeMessages` call this even
481
+ * when every message is duplicated or quarantined. Duplicate receipts within
482
+ * the drift limit can advance the clock without storing new messages.
483
+ */
484
+ const saveClock =
485
+ (deps: SqliteDep) =>
486
+ (timestamp: Timestamp): void => {
487
+ const bytes = timestampToTimestampBytes(timestamp);
488
+ deps.sqlite.exec(sql.prepared`
489
+ update evolu_config
490
+ set "clock" = ${bytes}
491
+ where "clock" < ${bytes};
492
+ `);
493
+ };
494
+
495
+ /**
496
+ * Checks the stored database version inside the startup transaction, before any
497
+ * other read. The legacy layout, one `protocolVersion` row that was always 1,
498
+ * is converted to database version 1 first. An older database is migrated one
499
+ * version at a time, and the record is updated in the same transaction, so a
500
+ * failed migration leaves both data and version unchanged.
501
+ */
502
+ const ensureDbVersion =
503
+ ({ sqlite }: SqliteDep) =>
504
+ (
505
+ versionColumns: ReadonlySet<string>,
506
+ ): Result<void, UnsupportedDbVersionError> => {
507
+ if (!versionColumns.has("dbVersion")) {
508
+ sqlite.exec(sql`
509
+ alter table evolu_version
510
+ rename column "protocolVersion" to "dbVersion";
374
511
  `);
375
- assertNonEmptyReadonlyArray(rows);
376
- currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock);
377
- } else {
378
- currentTimestamp = createInitialTimestamp(deps);
379
512
  }
380
513
 
381
- return {
382
- get: () => currentTimestamp,
383
-
384
- save: (timestamp) => {
385
- currentTimestamp = timestamp;
386
-
387
- deps.sqlite.exec(sql.prepared`
388
- update evolu_config
389
- set "clock" = ${timestampToTimestampBytes(timestamp)};
390
- `);
391
- },
392
- };
514
+ const { rows } = sqlite.exec<{ dbVersion: PositiveInt }>(sql`
515
+ select "dbVersion" from evolu_version;
516
+ `);
517
+ const storedVersion = rows[0].dbVersion;
518
+ if (storedVersion > dbVersion) {
519
+ return err({
520
+ type: "UnsupportedDbVersionError",
521
+ storedVersion,
522
+ supportedVersion: dbVersion,
523
+ });
524
+ }
525
+ if (storedVersion < dbVersion) {
526
+ if (storedVersion < 2) migrateToVersion2({ sqlite });
527
+ sqlite.exec(sql`update evolu_version set "dbVersion" = ${dbVersion};`);
528
+ }
529
+ return ok();
393
530
  };
394
531
 
532
+ /**
533
+ * Version 2 records why a message is quarantined, whether this database stamped
534
+ * or received it, and when, and adds the index that startup release reads. Rows
535
+ * from version 1 get the defaults: schema quarantine of a received message with
536
+ * an unknown quarantine time. See {@link QuarantineReason}.
537
+ */
538
+ const migrateToVersion2 = ({ sqlite }: SqliteDep): void => {
539
+ for (const query of [
540
+ sql`
541
+ alter table evolu_message_quarantine
542
+ add column "reason" integer not null default ${sql.raw(
543
+ String(QuarantineReason.Schema),
544
+ )};
545
+ `,
546
+ sql`
547
+ alter table evolu_message_quarantine
548
+ add column "origin" integer not null default ${sql.raw(
549
+ String(QuarantineOrigin.ReceivedMessage),
550
+ )};
551
+ `,
552
+ sql`
553
+ alter table evolu_message_quarantine
554
+ add column "quarantinedAt" integer;
555
+ `,
556
+ sql`
557
+ create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
558
+ "reason",
559
+ "timestamp"
560
+ );
561
+ `,
562
+ ]) {
563
+ sqlite.exec(query);
564
+ }
565
+ };
566
+
395
567
  const initializeDb =
396
568
  ({ sqlite }: SqliteDep) =>
397
569
  (initialClock: Timestamp): void => {
398
570
  for (const query of [
571
+ // The database version record; see the module documentation.
399
572
  sql`
400
573
  create table evolu_version (
401
- "protocolVersion" integer not null
574
+ "dbVersion" integer not null
402
575
  )
403
576
  strict;
404
577
  `,
405
578
 
406
579
  sql`
407
- insert into evolu_version ("protocolVersion")
408
- values (${protocolVersion});
580
+ insert into evolu_version ("dbVersion")
581
+ values (${dbVersion});
409
582
  `,
410
583
 
411
584
  sql`
@@ -458,7 +631,7 @@ const initializeDb =
458
631
  `,
459
632
 
460
633
  /**
461
- * Stores messages with unknown schema in a quarantine table.
634
+ * Stores unapplied messages with their quarantine reason.
462
635
  *
463
636
  * When a device receives sync messages containing tables or columns that
464
637
  * don't exist in its current schema (e.g., from a newer app version),
@@ -470,6 +643,16 @@ const initializeDb =
470
643
  * 3. Partial messages work - known columns go to app tables, unknown to
471
644
  * quarantine
472
645
  *
646
+ * Clock-drift quarantine preserves every column of the affected message.
647
+ * It is released at startup once system time comes within the drift limit
648
+ * of the message's timestamp; see the Timestamp module.
649
+ *
650
+ * Each row records why it was not applied (`reason`), whether this
651
+ * database stamped the message for a local mutation or received it
652
+ * (`origin`), and the captured system time of the request that
653
+ * quarantined it (`quarantinedAt`). Quarantine is not reported as an
654
+ * error; applications watch this table through queries.
655
+ *
473
656
  * The `union all` query in `readDbChange` combines `evolu_history` and
474
657
  * this table, ensuring all data (known and unknown) is included when
475
658
  * syncing to other devices.
@@ -482,6 +665,13 @@ const initializeDb =
482
665
  "id" blob not null,
483
666
  "column" text not null,
484
667
  "value" any,
668
+ "reason" integer not null default ${sql.raw(
669
+ String(QuarantineReason.Schema),
670
+ )},
671
+ "origin" integer not null default ${sql.raw(
672
+ String(QuarantineOrigin.ReceivedMessage),
673
+ )},
674
+ "quarantinedAt" integer,
485
675
  primary key ("ownerId", "timestamp", "table", "id", "column")
486
676
  )
487
677
  strict;
@@ -491,26 +681,36 @@ const initializeDb =
491
681
  }
492
682
 
493
683
  createBaseSqliteStorageTables({ sqlite });
684
+
685
+ // Startup release reads drift quarantine by reason and timestamp. Created
686
+ // last, as the migration creates it, so Evolu's own indexes are listed in one
687
+ // order in fresh and migrated databases.
688
+ sqlite.exec(sql`
689
+ create index evolu_message_quarantine_reason_timestamp on evolu_message_quarantine (
690
+ "reason",
691
+ "timestamp"
692
+ );
693
+ `);
494
694
  };
495
695
 
496
696
  const tryApplyQuarantinedMessages = (
497
697
  deps: SqliteDep & SqliteSchemaDep,
498
698
  ): void => {
499
- const rows = deps.sqlite.exec<{
500
- readonly ownerId: OwnerIdBytes;
501
- readonly timestamp: TimestampBytes;
502
- readonly table: string;
503
- readonly id: IdBytes;
504
- readonly column: string;
505
- readonly value: SqliteValue;
699
+ const { rows } = deps.sqlite.exec<{
700
+ ownerId: OwnerIdBytes;
701
+ timestamp: TimestampBytes;
702
+ table: string;
703
+ id: IdBytes;
704
+ column: string;
705
+ value: SqliteValue;
506
706
  }>(sql`
507
707
  select "ownerId", "timestamp", "table", "id", "column", "value"
508
- from evolu_message_quarantine;
708
+ from evolu_message_quarantine
709
+ where "reason" = ${QuarantineReason.Schema};
509
710
  `);
510
711
 
511
- for (const row of rows.rows) {
712
+ for (const row of rows) {
512
713
  if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue;
513
-
514
714
  applyColumnChange(deps)(
515
715
  row.ownerId,
516
716
  ownerIdBytesToOwnerId(row.ownerId),
@@ -522,7 +722,7 @@ const tryApplyQuarantinedMessages = (
522
722
  row.timestamp,
523
723
  );
524
724
 
525
- deps.sqlite.exec(sql`
725
+ deps.sqlite.exec(sql.prepared`
526
726
  delete from evolu_message_quarantine
527
727
  where
528
728
  "ownerId" = ${row.ownerId}
@@ -534,9 +734,73 @@ const tryApplyQuarantinedMessages = (
534
734
  }
535
735
  };
536
736
 
737
+ /**
738
+ * Moves drift quarantine within the drift limit to schema quarantine for
739
+ * application. Advances `clock` once per distinct timestamp in timestamp order,
740
+ * using one captured system time, so later local changes sort after released
741
+ * messages. Only timestamps within the drift limit are loaded. Returns the
742
+ * advanced clock, or `null` when nothing was released. Runs inside the startup
743
+ * transaction, before saving the clock and applying schema quarantine, so the
744
+ * SharedWorker learns the clock only after release commits.
745
+ */
746
+ const releaseDriftQuarantine =
747
+ (deps: SqliteDep & TimeDep & TimestampConfigDep) =>
748
+ (clock: Timestamp): Timestamp | null => {
749
+ const now = deps.time.now();
750
+ // Milliseconds are integers; floor the allowance before adding it so a
751
+ // fractional allowance cannot round up near the timestamp range ceiling.
752
+ const maxReleaseMillis = now + Math.floor(deps.timestampConfig.maxDrift);
753
+ assertType(NonNaNNumber, maxReleaseMillis);
754
+ const bound = timestampToTimestampBytes({
755
+ millis: saturateMillis(maxReleaseMillis),
756
+ counter: maxCounter,
757
+ nodeId: maxNodeId,
758
+ });
759
+ const { rows } = deps.sqlite.exec<{ timestamp: TimestampBytes }>(sql`
760
+ select distinct "timestamp"
761
+ from evolu_message_quarantine
762
+ where
763
+ "reason" = ${QuarantineReason.TimestampDrift}
764
+ and "timestamp" <= ${bound}
765
+ order by "timestamp";
766
+ `);
767
+ if (rows.length === 0) return null;
768
+
769
+ const receive = receiveTimestamp(deps);
770
+ let nextClock = clock;
771
+ for (const { timestamp } of rows) {
772
+ const remote = timestampBytesToTimestamp(timestamp);
773
+ const next = receive(nextClock, remote, now);
774
+ if (next.ok) {
775
+ nextClock = next.value;
776
+ } else {
777
+ assert(
778
+ next.error.cause === "local",
779
+ "The query bound excludes remote drift at the captured time.",
780
+ );
781
+ nextClock = next.error.timestamp;
782
+ }
783
+ }
784
+
785
+ // Drift checks passed; mark these rows for the next schema pass.
786
+ deps.sqlite.exec(sql`
787
+ update evolu_message_quarantine
788
+ set "reason" = ${QuarantineReason.Schema}
789
+ where
790
+ "reason" = ${QuarantineReason.TimestampDrift}
791
+ and "timestamp" <= ${bound};
792
+ `);
793
+
794
+ return nextClock;
795
+ };
796
+
537
797
  const validateColumnValue =
538
798
  (deps: SqliteSchemaDep) =>
539
799
  (table: string, column: string, _value: SqliteValue): boolean => {
800
+ // Local-only tables never sync, so a received change to one comes from
801
+ // non-standard code. It stays in quarantine, stored for sync but never
802
+ // applied.
803
+ if (isLocalOnlyTable(table)) return false;
540
804
  const schemaColumns = getOwnProp(deps.sqliteSchema.tables, table);
541
805
  return (
542
806
  schemaColumns != null &&
@@ -604,237 +868,231 @@ const applyColumnChange =
604
868
  * implementation, and switch owner encryption keys between requests.
605
869
  */
606
870
  interface ClientStorage extends Storage, BaseSqliteStorage {
607
- readonly setRequestContext: (encryptionKey: EncryptionKey) => void;
871
+ readonly setRequestContext: (
872
+ encryptionKey: EncryptionKey,
873
+ writeContext?: WriteContext,
874
+ ) => void;
608
875
  readonly didWriteMessages: () => boolean;
609
876
  }
610
877
 
611
- const createClientStorage =
612
- (
613
- deps: BaseSqliteStorageDep &
614
- ClockDep &
615
- SqliteSchemaDep &
616
- RandomBytesDep &
617
- SqliteDep &
618
- TimeDep &
619
- TimestampConfigDep,
620
- ) =>
621
- ({
622
- onError,
623
- }: {
624
- onError: (
625
- error:
626
- | ProtocolInvalidDataError
627
- | ProtocolTimestampMismatchError
628
- | DecryptWithXChaCha20Poly1305Error
629
- | TimestampCounterOverflowError
630
- | TimestampDriftError
631
- | TimestampTimeOutOfRangeError,
632
- ) => void;
633
- }): ClientStorage => {
634
- let encryptionKey: EncryptionKey | null = null;
635
- let didWriteMessages = false;
636
-
637
- const getEncryptionKey = (): EncryptionKey => {
638
- assertNonNullable(
639
- encryptionKey,
640
- "ClientStorage encryption key must be set",
641
- );
642
- return encryptionKey;
643
- };
644
-
645
- return {
646
- ...deps.baseSqliteStorage,
878
+ const createClientStorage = (
879
+ deps: BaseSqliteStorageDep &
880
+ RandomBytesDep &
881
+ SqliteDep &
882
+ SqliteSchemaDep &
883
+ TimestampConfigDep,
884
+ ): ClientStorage => {
885
+ let encryptionKey: EncryptionKey | null = null;
886
+ let didWriteMessages = false;
887
+ let writeContext: WriteContext | undefined;
888
+
889
+ const getEncryptionKey = (): EncryptionKey => {
890
+ assertNonNullable(
891
+ encryptionKey,
892
+ "ClientStorage encryption key must be set",
893
+ );
894
+ return encryptionKey;
895
+ };
647
896
 
648
- // DEV: ClientStorage was designed when Storage and Sync lived in the
649
- // same file.
650
- // This is safe because the worker handles one message at a time. We will
651
- // refactor it later, we will probably have to change Protocol API.
652
- setRequestContext: (nextEncryptionKey) => {
653
- encryptionKey = nextEncryptionKey;
654
- didWriteMessages = false;
655
- },
897
+ return {
898
+ ...deps.baseSqliteStorage,
656
899
 
657
- didWriteMessages: () => didWriteMessages,
900
+ // SharedWorker waits for the response before dispatching another request,
901
+ // so asynchronous sync processing cannot overlap this request context.
902
+ setRequestContext: (nextEncryptionKey, nextWriteContext) => {
903
+ encryptionKey = nextEncryptionKey;
904
+ writeContext = nextWriteContext;
905
+ didWriteMessages = false;
906
+ },
658
907
 
659
- // Not implemented yet.
660
- validateWriteKey: constFalse,
661
- setWriteKey: constVoid,
908
+ didWriteMessages: () => didWriteMessages,
662
909
 
663
- writeMessages: (ownerIdBytes, encryptedMessages) => () => {
664
- // TODO: Add quota checking for collaborative scenarios.
665
- // When receiving messages from other owners via relay broadcast,
666
- // check if this owner is within quota before accepting the data.
667
- // This prevents an owner from exceeding storage limits when receiving
668
- // data shared by other collaborators.
910
+ // Not implemented yet.
911
+ validateWriteKey: constFalse,
912
+ setWriteKey: constVoid,
669
913
 
670
- const messages: Array<CrdtMessage> = [];
671
- const currentEncryptionKey = getEncryptionKey();
914
+ writeMessages: (ownerIdBytes, encryptedMessages) => () => {
915
+ // TODO: Add quota checking for collaborative scenarios.
916
+ // When receiving messages from other owners via relay broadcast,
917
+ // check if this owner is within quota before accepting the data.
918
+ // This prevents an owner from exceeding storage limits when receiving
919
+ // data shared by other collaborators.
672
920
 
673
- for (const message of encryptedMessages) {
674
- const change = decryptAndDecodeDbChange(
675
- message,
676
- currentEncryptionKey,
677
- );
678
- if (!change.ok) {
679
- onError(change.error);
680
- return ok();
681
- }
682
- messages.push({ timestamp: message.timestamp, change: change.value });
683
- }
921
+ const messages: Array<CrdtMessage> = [];
922
+ const currentEncryptionKey = getEncryptionKey();
684
923
 
685
- let clockTimestamp = deps.clock.get();
924
+ for (const message of encryptedMessages) {
925
+ const change = decryptAndDecodeDbChange(message, currentEncryptionKey);
926
+ if (!change.ok) return err(change.error);
927
+ messages.push({ timestamp: message.timestamp, change: change.value });
928
+ }
686
929
 
687
- for (const message of messages) {
688
- const nextTimestamp = receiveTimestamp(deps)(
689
- clockTimestamp,
690
- message.timestamp,
691
- );
692
- if (!nextTimestamp.ok) {
693
- onError(nextTimestamp.error);
694
- return ok();
695
- }
696
- clockTimestamp = nextTimestamp.value;
697
- }
930
+ assertNonNullable(writeContext);
931
+ const { clock, now } = writeContext;
932
+ let clockTimestamp = clock.get();
933
+ const receive = receiveTimestamp(deps);
934
+
935
+ // The clock is computed over every message, duplicates included, so a
936
+ // retry with the same inputs reports the same clock. Writes for
937
+ // timestamps already in the owner's set are skipped by applyMessages.
938
+ for (const message of messages) {
939
+ const nextTimestamp = receive(clockTimestamp, message.timestamp, now);
940
+ if (!nextTimestamp.ok) {
941
+ if (nextTimestamp.error.cause === "remote") continue;
942
+ clockTimestamp = nextTimestamp.error.timestamp;
943
+ } else clockTimestamp = nextTimestamp.value;
944
+ }
698
945
 
699
- assertNonEmptyReadonlyArray(messages);
946
+ assertNonEmptyReadonlyArray(messages);
700
947
 
701
- return deps.sqlite.transaction(() => {
702
- applyMessages(deps)(ownerIdBytesToOwnerId(ownerIdBytes), messages);
703
- deps.clock.save(clockTimestamp);
704
- didWriteMessages = true;
705
- return ok();
706
- });
707
- },
948
+ let wroteNewMessages = false;
949
+ deps.sqlite.transaction(() => {
950
+ wroteNewMessages = applyMessages(deps)(
951
+ ownerIdBytesToOwnerId(ownerIdBytes),
952
+ messages,
953
+ QuarantineOrigin.ReceivedMessage,
954
+ now,
955
+ );
956
+ saveClock(deps)(clockTimestamp);
957
+ });
958
+ clock.set(clockTimestamp);
959
+ // A batch of duplicates changes no table, so queries need no refresh.
960
+ if (wroteNewMessages) didWriteMessages = true;
961
+ return ok();
962
+ },
708
963
 
709
- readDbChange: (ownerId, timestamp) => {
710
- const result = deps.sqlite.exec<{
711
- readonly table: string;
712
- readonly id: IdBytes;
713
- readonly column: string;
714
- readonly value: SqliteValue;
715
- }>(sql`
716
- select "table", "id", "column", "value"
717
- from evolu_history
718
- where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
719
- union all
720
- select "table", "id", "column", "value"
721
- from evolu_message_quarantine
722
- where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
723
- `);
964
+ readDbChange: (ownerId, timestamp) => {
965
+ const result = deps.sqlite.exec<{
966
+ readonly table: string;
967
+ readonly id: IdBytes;
968
+ readonly column: string;
969
+ readonly value: SqliteValue;
970
+ }>(sql`
971
+ select "table", "id", "column", "value"
972
+ from evolu_history
973
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
974
+ union all
975
+ select "table", "id", "column", "value"
976
+ from evolu_message_quarantine
977
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
978
+ `);
724
979
 
725
- const { rows } = result;
726
- assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
727
- const firstRow = firstInArray(rows);
728
-
729
- const values = createMutableRecord<string, SqliteValue>();
730
- let isInsert: DbChange["isInsert"] = false;
731
- let isDelete: DbChange["isDelete"] = null;
732
-
733
- for (const r of rows) {
734
- switch (r.column) {
735
- case "createdAt":
736
- isInsert = true;
737
- break;
738
- case "updatedAt":
739
- isInsert = false;
740
- break;
741
- case "isDeleted":
742
- assertType(SqliteBoolean, r.value);
743
- isDelete = sqliteBooleanToBoolean(r.value);
744
- break;
745
- default:
746
- values[r.column] = r.value;
747
- }
980
+ const { rows } = result;
981
+ assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
982
+ const firstRow = firstInArray(rows);
983
+
984
+ const values = createMutableRecord<string, SqliteValue>();
985
+ let isInsert: DbChange["isInsert"] = false;
986
+ let isDelete: DbChange["isDelete"] = null;
987
+
988
+ for (const r of rows) {
989
+ switch (r.column) {
990
+ case "createdAt":
991
+ isInsert = true;
992
+ break;
993
+ case "updatedAt":
994
+ isInsert = false;
995
+ break;
996
+ case "isDeleted":
997
+ assertType(SqliteBoolean, r.value);
998
+ isDelete = sqliteBooleanToBoolean(r.value);
999
+ break;
1000
+ default:
1001
+ values[r.column] = r.value;
748
1002
  }
1003
+ }
749
1004
 
750
- const message: CrdtMessage = {
751
- timestamp: timestampBytesToTimestamp(timestamp),
752
- change: DbChange.orThrow({
753
- table: firstRow.table,
754
- id: idBytesToId(firstRow.id),
755
- values,
756
- isInsert,
757
- isDelete,
758
- }),
759
- };
760
-
761
- return encodeAndEncryptDbChange(deps)(message, getEncryptionKey());
762
- },
763
- };
1005
+ const message: CrdtMessage = {
1006
+ timestamp: timestampBytesToTimestamp(timestamp),
1007
+ change: DbChange.orThrow({
1008
+ table: firstRow.table,
1009
+ id: idBytesToId(firstRow.id),
1010
+ values,
1011
+ isInsert,
1012
+ isDelete,
1013
+ }),
1014
+ };
1015
+
1016
+ return encodeAndEncryptDbChange(deps)(message, getEncryptionKey());
1017
+ },
764
1018
  };
1019
+ };
765
1020
 
766
1021
  const handleMutation =
767
1022
  (
768
1023
  deps: BaseSqliteStorageDep &
769
1024
  ClockDep &
770
- SqliteSchemaDep &
771
- RandomBytesDep &
772
1025
  SqliteDep &
773
- TimeDep &
1026
+ SqliteSchemaDep &
774
1027
  TimestampConfigDep,
775
1028
  ) =>
776
1029
  (
777
- message: ExtractTyped<
778
- ExtractTyped<DbWorkerRequest, "ForEvolu">["message"],
779
- "Mutate"
780
- >,
781
- ): Result<
782
- {
783
- readonly type: "Mutate";
784
- readonly messagesByOwnerId: ReadonlyMap<
785
- OwnerId,
786
- NonEmptyReadonlyArray<CrdtMessage>
787
- >;
788
- readonly rowsByQuery: RowsByQueryMap;
789
- },
790
- | TimestampDriftError
791
- | TimestampCounterOverflowError
792
- | TimestampTimeOutOfRangeError
793
- > =>
794
- deps.sqlite.transaction(() => {
795
- const messagesByOwnerId = new Map<OwnerId, NonEmptyArray<CrdtMessage>>();
796
- let clockTimestamp = deps.clock.get();
797
- let clockChanged = false;
798
-
799
- for (const change of message.changes) {
800
- if (change.table.startsWith("_")) {
801
- applyLocalOnlyChange(deps)(change);
802
- continue;
803
- }
804
-
805
- const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
806
- if (!nextTimestamp.ok) return nextTimestamp;
1030
+ message: ExtractTyped<EvoluInput, "Mutate">,
1031
+ now: Millis,
1032
+ ): {
1033
+ readonly type: "Mutate";
1034
+ readonly clock: Timestamp;
1035
+ readonly messagesByOwnerId: ReadonlyMap<
1036
+ OwnerId,
1037
+ NonEmptyReadonlyArray<CrdtMessage>
1038
+ >;
1039
+ readonly rowsByQuery: RowsByQueryMap;
1040
+ } =>
1041
+ getOk(
1042
+ deps.sqlite.transaction(() => {
1043
+ const messagesByOwnerId = new Map<
1044
+ OwnerId,
1045
+ NonEmptyArray<CrdtMessage>
1046
+ >();
1047
+ let clockTimestamp = deps.clock.get();
807
1048
 
808
- clockTimestamp = nextTimestamp.value;
809
- clockChanged = true;
1049
+ for (const change of message.changes) {
1050
+ if (isLocalOnlyTable(change.table)) {
1051
+ applyLocalOnlyChange(deps)(change, now);
1052
+ continue;
1053
+ }
810
1054
 
811
- const { ownerId, ...dbChange } = change;
812
- const message: CrdtMessage = {
813
- timestamp: clockTimestamp,
814
- change: dbChange,
815
- };
1055
+ // A drifted change still receives the next timestamp; applyMessages
1056
+ // stores it in quarantine instead of its table.
1057
+ const nextTimestamp = sendTimestamp(deps)(clockTimestamp, now);
1058
+ clockTimestamp = nextTimestamp.ok
1059
+ ? nextTimestamp.value
1060
+ : nextTimestamp.error.timestamp;
1061
+
1062
+ const { ownerId, ...dbChange } = change;
1063
+ const message: CrdtMessage = {
1064
+ timestamp: clockTimestamp,
1065
+ change: dbChange,
1066
+ };
816
1067
 
817
- const messages = messagesByOwnerId.get(ownerId);
818
- if (messages) messages.push(message);
819
- else messagesByOwnerId.set(ownerId, [message]);
820
- }
1068
+ const messages = messagesByOwnerId.get(ownerId);
1069
+ if (messages) messages.push(message);
1070
+ else messagesByOwnerId.set(ownerId, [message]);
1071
+ }
821
1072
 
822
- for (const [ownerId, messages] of messagesByOwnerId) {
823
- applyMessages(deps)(ownerId, messages);
824
- }
1073
+ for (const [ownerId, messages] of messagesByOwnerId) {
1074
+ applyMessages(deps)(
1075
+ ownerId,
1076
+ messages,
1077
+ QuarantineOrigin.LocalMutation,
1078
+ now,
1079
+ );
1080
+ }
825
1081
 
826
- if (clockChanged) deps.clock.save(clockTimestamp);
1082
+ if (messagesByOwnerId.size > 0) saveClock(deps)(clockTimestamp);
827
1083
 
828
- return ok({
829
- type: "Mutate",
830
- messagesByOwnerId,
831
- rowsByQuery: loadQueries(deps)(message.subscribedQueries),
832
- });
833
- });
1084
+ return ok({
1085
+ type: "Mutate",
1086
+ clock: clockTimestamp,
1087
+ messagesByOwnerId,
1088
+ rowsByQuery: loadQueries(deps)(message.subscribedQueries),
1089
+ });
1090
+ }),
1091
+ );
834
1092
 
835
1093
  const applyLocalOnlyChange =
836
- (deps: SqliteDep & TimeDep) =>
837
- (change: MutationChange): void => {
1094
+ (deps: SqliteDep) =>
1095
+ (change: MutationChange, now: Millis): void => {
838
1096
  if (change.isDelete) {
839
1097
  deps.sqlite.exec(sql`
840
1098
  delete from ${sql.identifier(change.table)}
@@ -842,7 +1100,7 @@ const applyLocalOnlyChange =
842
1100
  `);
843
1101
  } else {
844
1102
  const ownerId = change.ownerId;
845
- const columns = dbChangeToColumns(change, deps.time.now());
1103
+ const columns = dbChangeToColumns(change, now);
846
1104
 
847
1105
  for (const [column, value] of columns) {
848
1106
  assertNotUndefined(value);
@@ -857,10 +1115,28 @@ const applyLocalOnlyChange =
857
1115
  }
858
1116
  };
859
1117
 
1118
+ /**
1119
+ * Stores messages for an owner and applies them to their tables. Drifted
1120
+ * messages and columns the schema does not define go to quarantine instead.
1121
+ * Uses the request's captured time to classify drift, matching timestamp
1122
+ * generation. Returns whether any message was new; the rest were stored
1123
+ * before.
1124
+ */
860
1125
  const applyMessages =
861
- (deps: BaseSqliteStorageDep & ClockDep & SqliteSchemaDep & SqliteDep) =>
862
- (ownerId: OwnerId, messages: NonEmptyReadonlyArray<CrdtMessage>): void => {
1126
+ (
1127
+ deps: BaseSqliteStorageDep &
1128
+ SqliteDep &
1129
+ SqliteSchemaDep &
1130
+ TimestampConfigDep,
1131
+ ) =>
1132
+ (
1133
+ ownerId: OwnerId,
1134
+ messages: NonEmptyReadonlyArray<CrdtMessage>,
1135
+ origin: QuarantineOrigin,
1136
+ now: Millis,
1137
+ ): boolean => {
863
1138
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
1139
+ let wroteNewMessages = false;
864
1140
 
865
1141
  const usage = readOwnerUsageOrDefault(deps)(
866
1142
  ownerIdBytes,
@@ -870,13 +1146,36 @@ const applyMessages =
870
1146
  let { firstTimestamp, lastTimestamp } = usage;
871
1147
 
872
1148
  for (const { timestamp, change } of messages) {
1149
+ const timestampBytes = timestampToTimestampBytes(timestamp);
1150
+
1151
+ let strategy;
1152
+ [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
1153
+ timestampBytes,
1154
+ firstTimestamp,
1155
+ lastTimestamp,
1156
+ );
1157
+
1158
+ // A timestamp already in the set was applied or quarantined before.
1159
+ // Skipping it preserves that decision and makes duplicate delivery and
1160
+ // retries idempotent without a separate lookup.
1161
+ const isNew = deps.baseSqliteStorage.insertTimestamp(
1162
+ ownerIdBytes,
1163
+ timestampBytes,
1164
+ strategy,
1165
+ );
1166
+ if (!isNew) continue;
1167
+ wroteNewMessages = true;
1168
+
1169
+ const hasDrift = isTimestampBeyondMaxDrift(deps)(timestamp.millis, now);
873
1170
  const columns = dbChangeToColumns(change, timestamp.millis);
874
1171
  const idBytes = idToIdBytes(change.id);
875
- const timestampBytes = timestampToTimestampBytes(timestamp);
876
1172
 
877
1173
  for (const [column, value] of columns) {
878
1174
  assertNotUndefined(value);
879
- if (validateColumnValue(deps)(change.table, column, value)) {
1175
+ if (
1176
+ !hasDrift &&
1177
+ validateColumnValue(deps)(change.table, column, value)
1178
+ ) {
880
1179
  applyColumnChange(deps)(
881
1180
  ownerIdBytes,
882
1181
  ownerId,
@@ -890,7 +1189,17 @@ const applyMessages =
890
1189
  } else {
891
1190
  deps.sqlite.exec(sql.prepared`
892
1191
  insert into evolu_message_quarantine
893
- ("ownerId", "timestamp", "table", "id", "column", "value")
1192
+ (
1193
+ "ownerId",
1194
+ "timestamp",
1195
+ "table",
1196
+ "id",
1197
+ "column",
1198
+ "value",
1199
+ "reason",
1200
+ "origin",
1201
+ "quarantinedAt"
1202
+ )
894
1203
  values
895
1204
  (
896
1205
  ${ownerIdBytes},
@@ -898,38 +1207,34 @@ const applyMessages =
898
1207
  ${change.table},
899
1208
  ${idBytes},
900
1209
  ${column},
901
- ${value}
1210
+ ${value},
1211
+ ${hasDrift
1212
+ ? QuarantineReason.TimestampDrift
1213
+ : QuarantineReason.Schema},
1214
+ ${origin},
1215
+ ${now}
902
1216
  )
903
1217
  on conflict do nothing;
904
1218
  `);
905
1219
  }
906
1220
  }
1221
+ }
907
1222
 
908
- let strategy;
909
- [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
910
- timestampBytes,
1223
+ if (wroteNewMessages) {
1224
+ /**
1225
+ * TODO: Implement proper storedBytes tracking for client using received
1226
+ * and sent encrypted message sizes.
1227
+ */
1228
+ updateOwnerUsage(deps)(
1229
+ ownerIdBytes,
1230
+ // Placeholder until proper tracking implemented
1231
+ onePositiveInt,
911
1232
  firstTimestamp,
912
1233
  lastTimestamp,
913
1234
  );
914
-
915
- deps.baseSqliteStorage.insertTimestamp(
916
- ownerIdBytes,
917
- timestampBytes,
918
- strategy,
919
- );
920
1235
  }
921
1236
 
922
- /**
923
- * TODO: Implement proper storedBytes tracking for client using received and
924
- * sent encrypted message sizes.
925
- */
926
- updateOwnerUsage(deps)(
927
- ownerIdBytes,
928
- // Placeholder until proper tracking implemented
929
- onePositiveInt,
930
- firstTimestamp,
931
- lastTimestamp,
932
- );
1237
+ return wroteNewMessages;
933
1238
  };
934
1239
 
935
1240
  const dbChangeToColumns = (change: DbChange, now: Millis) => {