@evolu/common 8.11.0 → 8.13.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 (72) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Callbacks.d.ts +12 -1
  5. package/dist/src/Callbacks.d.ts.map +1 -1
  6. package/dist/src/Callbacks.js +3 -0
  7. package/dist/src/Error.d.ts +10 -4
  8. package/dist/src/Error.d.ts.map +1 -1
  9. package/dist/src/Error.js +10 -4
  10. package/dist/src/Object.d.ts +65 -0
  11. package/dist/src/Object.d.ts.map +1 -1
  12. package/dist/src/Object.js +142 -0
  13. package/dist/src/Resource.d.ts +0 -5
  14. package/dist/src/Resource.d.ts.map +1 -1
  15. package/dist/src/Resource.js +6 -13
  16. package/dist/src/Sqlite.d.ts.map +1 -1
  17. package/dist/src/Sqlite.js +7 -0
  18. package/dist/src/Task.d.ts +10 -8
  19. package/dist/src/Task.d.ts.map +1 -1
  20. package/dist/src/Task.js +41 -5
  21. package/dist/src/Worker.d.ts +3 -3
  22. package/dist/src/index.d.ts +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +1 -1
  25. package/dist/src/local-first/Db.d.ts +8 -3
  26. package/dist/src/local-first/Db.d.ts.map +1 -1
  27. package/dist/src/local-first/Db.js +56 -19
  28. package/dist/src/local-first/Evolu.d.ts +142 -24
  29. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  30. package/dist/src/local-first/Evolu.js +109 -8
  31. package/dist/src/local-first/Owner.d.ts +9 -0
  32. package/dist/src/local-first/Owner.d.ts.map +1 -1
  33. package/dist/src/local-first/Owner.js +9 -0
  34. package/dist/src/local-first/Protocol.d.ts +18 -7
  35. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  36. package/dist/src/local-first/Protocol.js +45 -28
  37. package/dist/src/local-first/Relay.d.ts.map +1 -1
  38. package/dist/src/local-first/Relay.js +4 -2
  39. package/dist/src/local-first/Schema.d.ts +18 -3
  40. package/dist/src/local-first/Schema.d.ts.map +1 -1
  41. package/dist/src/local-first/Shared.d.ts +523 -133
  42. package/dist/src/local-first/Shared.d.ts.map +1 -1
  43. package/dist/src/local-first/Shared.js +696 -256
  44. package/dist/src/local-first/Storage.d.ts +19 -15
  45. package/dist/src/local-first/Storage.d.ts.map +1 -1
  46. package/dist/src/local-first/Storage.js +4 -2
  47. package/package.json +1 -1
  48. package/src/Bytes.test.ts +27 -0
  49. package/src/Bytes.ts +58 -2
  50. package/src/Callbacks.test.ts +20 -0
  51. package/src/Callbacks.ts +17 -1
  52. package/src/Error.ts +10 -4
  53. package/src/Object.test.ts +296 -0
  54. package/src/Object.ts +163 -0
  55. package/src/Resource.test.ts +20 -16
  56. package/src/Resource.ts +6 -20
  57. package/src/Sqlite.ts +7 -0
  58. package/src/Task.test.ts +233 -62
  59. package/src/Task.ts +47 -13
  60. package/src/Worker.ts +3 -3
  61. package/src/index.ts +6 -1
  62. package/src/local-first/Db.ts +88 -18
  63. package/src/local-first/Evolu.test.ts +589 -2
  64. package/src/local-first/Evolu.ts +285 -36
  65. package/src/local-first/Owner.ts +9 -0
  66. package/src/local-first/Protocol.test.ts +59 -60
  67. package/src/local-first/Protocol.ts +67 -47
  68. package/src/local-first/Relay.ts +4 -2
  69. package/src/local-first/Schema.ts +20 -3
  70. package/src/local-first/Shared.test.ts +2633 -646
  71. package/src/local-first/Shared.ts +1192 -364
  72. package/src/local-first/Storage.ts +24 -23
@@ -207,6 +207,23 @@ test("encodeSqliteValue/decodeSqliteValue preserves an own __proto__ JSON object
207
207
  assertEqual(decodeSqliteValue(buffer), value);
208
208
  });
209
209
 
210
+ test("encodeSqliteValue encodes JSON nested deeper than decoding allows as a string", () => {
211
+ for (const [depth, type] of [
212
+ [1_000, ProtocolValueType.Json],
213
+ [1_001, ProtocolValueType.String],
214
+ // Deep enough to overflow the stack of a recursive JSON.stringify.
215
+ [20_000, ProtocolValueType.String],
216
+ ] as const) {
217
+ const value = "[".repeat(depth) + "]".repeat(depth);
218
+ const buffer = createBuffer();
219
+
220
+ encodeSqliteValue(buffer, value);
221
+
222
+ assertEqual(buffer.unwrap()[0], type);
223
+ assertEqual(decodeSqliteValue(buffer), value);
224
+ }
225
+ });
226
+
210
227
  test("encodeSqliteValue/decodeSqliteValue property tests", () => {
211
228
  const deps = testCreateDeps();
212
229
  // Property test: round-trip encoding/decoding should preserve the value
@@ -933,7 +950,7 @@ describe("E2E errors", () => {
933
950
  );
934
951
  });
935
952
 
936
- it("rejected relay writes report quota and other causes distinctly", async () => {
953
+ it("reports a rejected relay write as a quota and a thrown one as a write failure", async () => {
937
954
  const deps = testCreateDeps();
938
955
  const initiatorMessage = createProtocolMessageFromCrdtMessages(deps)(
939
956
  testAppOwner,
@@ -944,50 +961,48 @@ describe("E2E errors", () => {
944
961
  },
945
962
  ],
946
963
  );
947
- /** Returns the relay's response and what it logged for the rejection. */
948
- const relayResponseFor = async (error: StorageWriteMessagesError) => {
964
+ /** Returns the relay's response and what it logged for the write. */
965
+ const relayResponseFor = async (
966
+ writeMessages: StorageDep["storage"]["writeMessages"],
967
+ ) => {
949
968
  await using run = testCreateRun({
950
969
  storage: {
951
970
  ...shouldNotBeCalledStorageDep.storage,
952
971
  validateWriteKey: () => true,
953
- writeMessages: () => () => err(error),
972
+ writeMessages,
954
973
  },
955
974
  } satisfies StorageDep);
956
975
  const { message } = await run.orThrow(
957
976
  applyProtocolMessageAsRelay(initiatorMessage),
958
977
  );
959
- return {
960
- message,
961
- logged: run.deps.console
962
- .getEntriesSnapshot()
963
- .map(({ method, args }) => ({ method, args })),
964
- };
978
+ return { message, logged: run.deps.console.getEntriesSnapshot() };
965
979
  };
966
980
 
967
981
  await using run = testCreateRun(shouldNotBeCalledStorageDep);
968
982
  // A quota rejection is expected, so the relay does not log it.
969
- const quota = await relayResponseFor({
970
- type: "StorageQuotaError",
971
- ownerId: testAppOwner.id,
972
- });
983
+ const quota = await relayResponseFor(
984
+ () => () => err({ type: "StorageQuotaError", ownerId: testAppOwner.id }),
985
+ );
973
986
  assertEqual(
974
987
  await run(applyProtocolMessageAsClient(quota.message)),
975
988
  err({ type: "ProtocolQuotaError", ownerId: testAppOwner.id }),
976
989
  );
977
990
  assertEqual(quota.logged, []);
978
- // Any other storage rejection is a relay write failure, not a quota, and
979
- // the relay logs its cause.
980
- const mismatch: StorageWriteMessagesError = {
981
- type: "ProtocolTimestampMismatchError",
982
- expected: timestampBytesToTimestamp(testTimestampsAsc[0]),
983
- timestamp: timestampBytesToTimestamp(testTimestampsAsc[1]),
984
- };
985
- const other = await relayResponseFor(mismatch);
991
+ // A thrown write is a relay write failure, not a quota, and the relay
992
+ // logs it.
993
+ const failure = new Error("write failed");
994
+ const thrown = await relayResponseFor(() => {
995
+ throw failure;
996
+ });
986
997
  assertEqual(
987
- await run(applyProtocolMessageAsClient(other.message)),
998
+ await run(applyProtocolMessageAsClient(thrown.message)),
988
999
  err({ type: "ProtocolWriteError", ownerId: testAppOwner.id }),
989
1000
  );
990
- assertEqual(other.logged, [{ method: "error", args: [mismatch] }]);
1001
+ assertEqual(
1002
+ thrown.logged.map(({ method }) => method),
1003
+ ["error"],
1004
+ );
1005
+ assertSame(thrown.logged[0]?.args[0], failure);
991
1006
  });
992
1007
  });
993
1008
 
@@ -1222,7 +1237,7 @@ describe("applyProtocolMessageAsClient results", () => {
1222
1237
  assertOk(result, { type: "Readonly" });
1223
1238
  });
1224
1239
 
1225
- it("preserves expected storage write rejection causes", async () => {
1240
+ it("preserves a storage write rejection", async () => {
1226
1241
  const deps = testCreateDeps();
1227
1242
  const input = createResponse();
1228
1243
  input.addMessage(
@@ -1233,42 +1248,26 @@ describe("applyProtocolMessageAsClient results", () => {
1233
1248
  );
1234
1249
  const message = input.unwrap();
1235
1250
 
1236
- const errors: ReadonlyArray<StorageWriteMessagesError> = [
1237
- {
1238
- type: "DecryptWithXChaCha20Poly1305Error",
1239
- error: new Error("decryption failed"),
1240
- },
1241
- {
1242
- type: "ProtocolInvalidDataError",
1243
- data: Uint8Array.of(255),
1244
- error: new Error("decoding failed"),
1245
- },
1246
- {
1247
- type: "ProtocolTimestampMismatchError",
1248
- expected: timestampBytesToTimestamp(testTimestampsAsc[0]),
1249
- timestamp: timestampBytesToTimestamp(testTimestampsAsc[1]),
1251
+ const error: StorageWriteMessagesError = {
1252
+ type: "StorageQuotaError",
1253
+ ownerId: testAppOwner.id,
1254
+ };
1255
+ await using run = testCreateRun({
1256
+ storage: {
1257
+ ...shouldNotBeCalledStorageDep.storage,
1258
+ writeMessages: () => () => err(error),
1250
1259
  },
1251
- { type: "StorageQuotaError", ownerId: testAppOwner.id },
1252
- ];
1253
-
1254
- for (const error of errors) {
1255
- await using run = testCreateRun({
1256
- storage: {
1257
- ...shouldNotBeCalledStorageDep.storage,
1258
- writeMessages: () => () => err(error),
1259
- },
1260
- } satisfies StorageDep);
1261
- const task = applyProtocolMessageAsClient(message, {
1262
- writeKey: testAppOwner.writeKey,
1263
- });
1264
- assertType<
1265
- InferTaskErr<typeof task>,
1266
- ProtocolError | StorageWriteMessagesError
1267
- >();
1268
- const result = await run(task);
1269
- assertErr(result);
1270
- assertSame(result.error, error);
1271
- }
1260
+ } satisfies StorageDep);
1261
+ const task = applyProtocolMessageAsClient(message, {
1262
+ writeKey: testAppOwner.writeKey,
1263
+ });
1264
+ assertType<
1265
+ InferTaskErr<typeof task>,
1266
+ ProtocolError | StorageWriteMessagesError
1267
+ >();
1268
+ const result = await run(task);
1269
+ assertErr(result);
1270
+ assertSame(result.error, error);
1272
1271
  });
1273
1272
 
1274
1273
  it("reports a thrown write as failed", async () => {
@@ -103,9 +103,9 @@
103
103
  * fit within the limit, the protocol automatically continues synchronization in
104
104
  * subsequent rounds using range-based reconciliation.
105
105
  *
106
- * Database mutations are limited to 640KB, which is smaller than the protocol
107
- * message limit to ensure efficient sync with
108
- * {@link defaultProtocolMessageRangesMaxSize}.
106
+ * Each mutation is limited to {@link maxMutationSize}, so every change fits one
107
+ * message of {@link defaultProtocolMessageMaxSize} next to the largest ranges
108
+ * section.
109
109
  *
110
110
  * ## Why Binary?
111
111
  *
@@ -190,6 +190,7 @@ import {
190
190
  import type { Brand } from "../Brand.ts";
191
191
  import {
192
192
  type Buffer,
193
+ BufferError,
193
194
  createBuffer,
194
195
  createRunLengthEncoder,
195
196
  decodeFlags,
@@ -248,7 +249,7 @@ import {
248
249
  zeroNonNegativeInt,
249
250
  } from "../Type.ts";
250
251
  import type { Predicate } from "../Types.ts";
251
- import type { Evolu } from "./Evolu.ts";
252
+ import type { Evolu, maxMutationSize } from "./Evolu.ts";
252
253
  import {
253
254
  type Owner,
254
255
  type OwnerError,
@@ -464,8 +465,9 @@ export interface ProtocolWriteKeyError
464
465
  extends OwnerError, Typed<"ProtocolWriteKeyError"> {}
465
466
 
466
467
  /**
467
- * Error indicating a serious relay-side write failure. Clients should log this
468
- * error and show a generic sync error to the user.
468
+ * Error indicating a serious relay-side write failure. Sync state shows it as
469
+ * the failure of that relay's route; apps show a generic sync error for the
470
+ * owner's `Error` status.
469
471
  */
470
472
  export interface ProtocolWriteError
471
473
  extends OwnerError, Typed<"ProtocolWriteError"> {}
@@ -488,8 +490,9 @@ export interface ProtocolQuotaError
488
490
  extends OwnerError, Typed<"ProtocolQuotaError"> {}
489
491
 
490
492
  /**
491
- * Error indicating a serious relay-side synchronization failure. Clients should
492
- * log this error and show a generic sync error to the user.
493
+ * Error indicating a serious relay-side synchronization failure. Sync state
494
+ * shows it as the failure of that relay's route; apps show a generic sync error
495
+ * for the owner's `Error` status.
493
496
  */
494
497
  export interface ProtocolSyncError
495
498
  extends OwnerError, Typed<"ProtocolSyncError"> {}
@@ -1285,13 +1288,9 @@ export const applyProtocolMessageAsRelay =
1285
1288
  );
1286
1289
 
1287
1290
  if (!result.ok) {
1288
- const isQuotaError = result.error.type === "StorageQuotaError";
1289
- if (!isQuotaError) run.deps.console.error(result.error);
1290
1291
  const message = createProtocolMessageBuffer(ownerId, {
1291
1292
  messageType: MessageType.Response,
1292
- errorCode: isQuotaError
1293
- ? ProtocolErrorCode.QuotaError
1294
- : ProtocolErrorCode.WriteError,
1293
+ errorCode: ProtocolErrorCode.QuotaError,
1295
1294
  }).unwrap();
1296
1295
  return ok({ type: "Response", message });
1297
1296
  }
@@ -1855,30 +1854,7 @@ export const encodeAndEncryptDbChange =
1855
1854
  (message: CrdtMessage, key: EncryptionKey): EncryptedDbChange => {
1856
1855
  const buffer = createBuffer();
1857
1856
 
1858
- encodeNonNegativeInt(buffer, protocolVersion);
1859
-
1860
- // Encode the timestamp to prevent tampering (e.g., a malicious relay
1861
- // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1862
- buffer.extend(timestampToTimestampBytes(message.timestamp));
1863
-
1864
- encodeFlags(buffer, [
1865
- message.change.isInsert,
1866
- // Encode nullable boolean as two flags: presence + value.
1867
- message.change.isDelete != null,
1868
- message.change.isDelete ?? false,
1869
- ]);
1870
-
1871
- encodeString(buffer, message.change.table);
1872
- buffer.extend(idToIdBytes(message.change.id));
1873
-
1874
- const entries = objectToEntries(message.change.values);
1875
-
1876
- encodeLength(buffer, entries);
1877
- for (const [column, value] of entries) {
1878
- assertNotUndefined(value);
1879
- encodeString(buffer, column);
1880
- encodeSqliteValue(buffer, value);
1881
- }
1857
+ encodeDbChange(buffer, message);
1882
1858
 
1883
1859
  // Add PADMÉ padding (ignored during decoding)
1884
1860
  buffer.extend(createPadmePadding(buffer.getLength()));
@@ -1896,6 +1872,41 @@ export const encodeAndEncryptDbChange =
1896
1872
  return buffer.unwrap() as EncryptedDbChange;
1897
1873
  };
1898
1874
 
1875
+ /**
1876
+ * Encodes a {@link CrdtMessage} as {@link encodeAndEncryptDbChange} does before
1877
+ * padding and encryption.
1878
+ *
1879
+ * {@link Evolu.getMutationSize} measures mutations with this encoding, so
1880
+ * {@link maxMutationSize} limits exactly what is encoded, and every change
1881
+ * within it fits one protocol message.
1882
+ */
1883
+ export const encodeDbChange = (buffer: Buffer, message: CrdtMessage): void => {
1884
+ encodeNonNegativeInt(buffer, protocolVersion);
1885
+
1886
+ // Encode the timestamp to prevent tampering (e.g., a malicious relay
1887
+ // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1888
+ buffer.extend(timestampToTimestampBytes(message.timestamp));
1889
+
1890
+ encodeFlags(buffer, [
1891
+ message.change.isInsert,
1892
+ // Encode nullable boolean as two flags: presence + value.
1893
+ message.change.isDelete != null,
1894
+ message.change.isDelete ?? false,
1895
+ ]);
1896
+
1897
+ encodeString(buffer, message.change.table);
1898
+ buffer.extend(idToIdBytes(message.change.id));
1899
+
1900
+ const entries = objectToEntries(message.change.values);
1901
+
1902
+ encodeLength(buffer, entries);
1903
+ for (const [column, value] of entries) {
1904
+ assertNotUndefined(value);
1905
+ encodeString(buffer, column);
1906
+ encodeSqliteValue(buffer, value);
1907
+ }
1908
+ };
1909
+
1899
1910
  /**
1900
1911
  * Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
1901
1912
  * owner's encryption key. Verifies that the embedded timestamp matches the
@@ -2069,21 +2080,30 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
2069
2080
  }
2070
2081
 
2071
2082
  const json = Json.from.parent(value);
2072
- // Only encode as Json if it survives JSON.parse/JSON.stringify round-trip.
2073
- // Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
2074
- // which would cause data corruption if we don't verify round-trip safety.
2075
- if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
2083
+ if (json.ok) {
2084
+ const jsonValue = jsonToJsonValue(json.value);
2076
2085
  jsonBuffer.reset();
2077
2086
  try {
2078
- encodeJsonValue(jsonBuffer, jsonToJsonValue(json.value));
2079
- const jsonBytes = jsonBuffer.unwrap();
2080
- encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2081
- encodeLength(buffer, jsonBytes);
2082
- buffer.extend(jsonBytes);
2087
+ // Encoding first rejects nesting deeper than decoding allows, before
2088
+ // the recursive JSON.stringify below could overflow the stack. Such
2089
+ // a value is encoded as a plain string.
2090
+ encodeJsonValue(jsonBuffer, jsonValue);
2091
+ // Only encode as Json if it survives JSON.parse/JSON.stringify
2092
+ // round-trip. Some valid JSON strings like "-0E0" get normalized to
2093
+ // "0" during parsing, which would cause data corruption if we don't
2094
+ // verify round-trip safety.
2095
+ if (JSON.stringify(jsonValue) === value) {
2096
+ const jsonBytes = jsonBuffer.unwrap();
2097
+ encodeNonNegativeInt(buffer, ProtocolValueType.Json);
2098
+ encodeLength(buffer, jsonBytes);
2099
+ buffer.extend(jsonBytes);
2100
+ return;
2101
+ }
2102
+ } catch (error) {
2103
+ if (!(error instanceof BufferError)) throw error;
2083
2104
  } finally {
2084
2105
  jsonBuffer.reset();
2085
2106
  }
2086
- return;
2087
2107
  }
2088
2108
 
2089
2109
  const base64Url = Base64Url.from.parent(value);
@@ -17,7 +17,7 @@ import { err, ok } from "../Result.ts";
17
17
  import type { SqliteDep } from "../Sqlite.ts";
18
18
  import { sql } from "../Sqlite.ts";
19
19
  import { createMutexByKey } from "../Task.ts";
20
- import { Name, PositiveInt, uint8ArrayToBase64Url } from "../Type.ts";
20
+ import { Name, NonNegativeInt, uint8ArrayToBase64Url } from "../Type.ts";
21
21
  import { isPromiseLike, type Awaitable } from "../Types.ts";
22
22
  import {
23
23
  OwnerId,
@@ -246,7 +246,9 @@ export const createRelaySqliteStorage =
246
246
  (sum, m) => sum + m.change.length,
247
247
  0,
248
248
  );
249
- const newStoredBytes = PositiveInt.orThrow(
249
+ // A sum of lengths can be zero. Throwing here would panic the
250
+ // relay's shared Run.
251
+ const newStoredBytes = NonNegativeInt.orThrow(
250
252
  (usage.storedBytes ?? 0) + incomingBytes,
251
253
  );
252
254
 
@@ -34,6 +34,7 @@ import {
34
34
  IdBytes,
35
35
  type InferType,
36
36
  NonEmptyTrimmedString100,
37
+ type NonEmptyTrimmedString1000,
37
38
  Null,
38
39
  nullOr,
39
40
  object,
@@ -42,6 +43,7 @@ import {
42
43
  type withDefault,
43
44
  } from "../Type.ts";
44
45
  import type { CompileTimeError, Simplify } from "../Types.ts";
46
+ import type { Evolu, EvoluErrorDep, maxMutationSize } from "./Evolu.ts";
45
47
  import type { AppOwner, OwnerIdBytes } from "./Owner.ts";
46
48
  import {
47
49
  OwnerEncryptionKey,
@@ -475,8 +477,17 @@ export type MutationKind = "insert" | "update" | "upsert";
475
477
  * inadvertently generate a large volume of CRDT messages. Each mutation
476
478
  * produces exactly one {@link CrdtMessage} containing all provided columns.
477
479
  *
478
- * Mutations never fail — values are already validated by the caller, and
479
- * changes are stored locally in SQLite.
480
+ * Each mutation must fit within {@link maxMutationSize}. Give every column a
481
+ * Type with a maximum length, such as {@link NonEmptyTrimmedString1000} or
482
+ * `maxLength(100_000)(Uint8Array)`, so that a table's largest values add up to
483
+ * less than the limit and input that is too large is rejected where it enters
484
+ * the app. A larger mutation throws before anything is saved, so the code after
485
+ * it does not run. Check unbounded input with {@link Evolu.getMutationSize}.
486
+ * Large binary data, such as images or videos, does not belong in a single
487
+ * mutation; a chunked API for it is planned.
488
+ *
489
+ * Binary values are copied when the mutation is made, so later changes to a
490
+ * `Uint8Array` do not change what is saved.
480
491
  *
481
492
  * - **insert**: all non-nullable columns required, nullable columns optional,
482
493
  * `id` omitted (auto-generated)
@@ -503,7 +514,13 @@ export interface MutationOptions {
503
514
  * Called after the mutation's changes are stored and subscribed queries
504
515
  * reflect them. Useful for follow-up work (e.g., notifications, navigation)
505
516
  * after insert, update, or upsert. It never runs when the database is
506
- * unavailable.
517
+ * unavailable or the mutation could not be stored, which
518
+ * {@link EvoluErrorDep.evoluError} reports.
519
+ *
520
+ * An Evolu instance stores its mutations in batches, each in one transaction.
521
+ * A batch holds the mutations made before Evolu sends it in a microtask,
522
+ * usually one synchronous block, and {@link Evolu.requestSync} sends it early.
523
+ * When one mutation cannot be stored, none of its batch is.
507
524
  *
508
525
  * Stored does not always mean visible. A change quarantined for
509
526
  * {@link QuarantineReason.TimestampDrift} is stored in