@evolu/common 8.17.0 → 8.19.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 (48) hide show
  1. package/dist/src/Bytes.d.ts +31 -14
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +48 -8
  4. package/dist/src/Error.d.ts.map +1 -1
  5. package/dist/src/Error.js +5 -1
  6. package/dist/src/Polyfills.d.ts.map +1 -1
  7. package/dist/src/Polyfills.js +3 -2
  8. package/dist/src/Sqlite.d.ts +1 -1
  9. package/dist/src/Task.d.ts +4 -3
  10. package/dist/src/Task.d.ts.map +1 -1
  11. package/dist/src/Task.js +4 -3
  12. package/dist/src/index.d.ts +1 -1
  13. package/dist/src/index.d.ts.map +1 -1
  14. package/dist/src/local-first/Db.d.ts +32 -3
  15. package/dist/src/local-first/Db.d.ts.map +1 -1
  16. package/dist/src/local-first/Db.js +30 -9
  17. package/dist/src/local-first/Evolu.d.ts +21 -6
  18. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  19. package/dist/src/local-first/Evolu.js +5 -1
  20. package/dist/src/local-first/Protocol.d.ts +215 -93
  21. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  22. package/dist/src/local-first/Protocol.js +799 -489
  23. package/dist/src/local-first/Relay.d.ts +9 -1
  24. package/dist/src/local-first/Relay.d.ts.map +1 -1
  25. package/dist/src/local-first/Relay.js +41 -36
  26. package/dist/src/local-first/Shared.d.ts +79 -53
  27. package/dist/src/local-first/Shared.d.ts.map +1 -1
  28. package/dist/src/local-first/Shared.js +67 -42
  29. package/dist/src/local-first/Storage.d.ts +80 -18
  30. package/dist/src/local-first/Storage.d.ts.map +1 -1
  31. package/dist/src/local-first/Storage.js +20 -4
  32. package/package.json +1 -1
  33. package/src/Bytes.test.ts +212 -0
  34. package/src/Bytes.ts +67 -17
  35. package/src/Error.ts +5 -1
  36. package/src/Polyfills.ts +3 -2
  37. package/src/Sqlite.ts +1 -1
  38. package/src/Task.ts +4 -3
  39. package/src/index.ts +4 -1
  40. package/src/local-first/Db.ts +76 -17
  41. package/src/local-first/Evolu.test.ts +43 -12
  42. package/src/local-first/Evolu.ts +34 -7
  43. package/src/local-first/Protocol.test.ts +1541 -44
  44. package/src/local-first/Protocol.ts +1070 -605
  45. package/src/local-first/Relay.ts +80 -69
  46. package/src/local-first/Shared.test.ts +37 -1
  47. package/src/local-first/Shared.ts +124 -73
  48. package/src/local-first/Storage.ts +100 -27
@@ -10,6 +10,7 @@ import { firstInArray, isNonEmptyArray } from "../Array.ts";
10
10
  import { assert, assertNonNullable } from "../Assert.ts";
11
11
  import type { Brand } from "../Brand.ts";
12
12
  import { concatByteArrays } from "../Bytes.ts";
13
+ import type { UnknownError } from "../Error.ts";
13
14
  import { decrement } from "../Number.ts";
14
15
  import type { RandomDep } from "../Random.ts";
15
16
  import { err, ok } from "../Result.ts";
@@ -36,9 +37,10 @@ import {
36
37
  type UnionType,
37
38
  } from "../Type.ts";
38
39
  import type { Awaitable } from "../Types.ts";
39
- import type { Owner, OwnerError, OwnerIdBytes } from "./Owner.ts";
40
+ import type { Evolu } from "./Evolu.ts";
41
+ import type { Owner, OwnerError, OwnerIdBytes, SharedOwner } from "./Owner.ts";
40
42
  import { OwnerId, OwnerWriteKey } from "./Owner.ts";
41
- import type { ProtocolQuotaError } from "./Protocol.ts";
43
+ import type { ProtocolQuotaError, ProtocolWriteError } from "./Protocol.ts";
42
44
  import { systemColumnsWithId } from "./Schema.ts";
43
45
  import type { syncStateToOwnerSyncStatus } from "./Shared.ts";
44
46
  import {
@@ -67,7 +69,10 @@ export interface StorageConfig {
67
69
  * asynchronous (for calling remote APIs).
68
70
  *
69
71
  * The callback returns a boolean rather than an error because error handling
70
- * and logging are the responsibility of the callback implementation.
72
+ * and logging are the responsibility of the callback implementation. It must
73
+ * not throw or reject, because a throw is a defect that shuts the relay down
74
+ * for a supervisor to restart it; a callback calling a remote service catches
75
+ * its failure and returns whether to allow the write.
71
76
  *
72
77
  * Relay deployments configure this callback. Client applications observe a
73
78
  * denied relay write as a {@link ProtocolQuotaError} in the `failure` of that
@@ -120,10 +125,9 @@ export interface StorageConfig {
120
125
  * that satisfies this contract.
121
126
  *
122
127
  * {@link Storage.writeMessages} returns the {@link StorageWriteMessagesError}
123
- * that made it store none of a batch. Implementations return expected write
124
- * rejections without reporting them; the caller owns reporting. The client
125
- * protocol forwards these errors unchanged, while the relay protocol maps them
126
- * to wire error codes.
128
+ * that made it store none of a batch. Implementations return it without
129
+ * reporting it; the caller owns reporting. The client protocol forwards these
130
+ * errors unchanged, while the relay protocol maps them to wire error codes.
127
131
  *
128
132
  * The Storage API is synchronous because SQLite's synchronous API is the
129
133
  * fastest way to use SQLite. Synchronous bindings (like better-sqlite3) call
@@ -134,11 +138,20 @@ export interface StorageConfig {
134
138
  * for async validation logic before writing to storage. The write operation
135
139
  * itself remains synchronous.
136
140
  *
141
+ * Indexes count an owner's timestamps in order from 0, and none may exceed the
142
+ * owner's size from {@link Storage.getSize}. Sync reads the size once per
143
+ * message and derives every index from it.
144
+ *
137
145
  * @group Core
138
146
  */
139
147
  export interface Storage {
148
+ /** Returns the number of the owner's timestamps. */
140
149
  readonly getSize: (ownerId: OwnerIdBytes) => NonNegativeInt;
141
150
 
151
+ /**
152
+ * Returns the {@link Fingerprint} of the owner's timestamps from index `begin`
153
+ * up to, but not including, `end`, where `begin` is at most `end`.
154
+ */
142
155
  readonly fingerprint: (
143
156
  ownerId: OwnerIdBytes,
144
157
  begin: NonNegativeInt,
@@ -148,6 +161,9 @@ export interface Storage {
148
161
  /**
149
162
  * Computes fingerprints with their upper bounds in one call.
150
163
  *
164
+ * Each bucket is the end index of a range that starts at the previous bucket,
165
+ * or at 0 for the first, so the buckets must be ascending.
166
+ *
151
167
  * This function can be replaced with many fingerprint/findLowerBound calls,
152
168
  * but implementations can leverage it for batching and more efficient
153
169
  * fingerprint computation.
@@ -158,6 +174,16 @@ export interface Storage {
158
174
  upperBound?: RangeUpperBound,
159
175
  ) => ReadonlyArray<FingerprintRange>;
160
176
 
177
+ /**
178
+ * Returns the index of the owner's first timestamp not below `upperBound`,
179
+ * counted from the owner's first timestamp, or `end` when there is none or
180
+ * `upperBound` is {@link InfiniteUpperBound}.
181
+ *
182
+ * `begin` must be 0 or the result for an earlier bound not above
183
+ * `upperBound`, and `end` the owner's size, so the result lies in [`begin`,
184
+ * `end`]. When `end` is 0 or equals `begin`, the result is `end`. Sync meets
185
+ * this because the protocol rejects range upper bounds that decrease.
186
+ */
161
187
  readonly findLowerBound: (
162
188
  ownerId: OwnerIdBytes,
163
189
  begin: NonNegativeInt,
@@ -165,6 +191,11 @@ export interface Storage {
165
191
  upperBound: RangeUpperBound,
166
192
  ) => NonNegativeInt;
167
193
 
194
+ /**
195
+ * Calls `callback` with the owner's timestamps and their indexes from `begin`
196
+ * up to, but not including, `end`, where `begin` is at most `end`, until the
197
+ * callback returns `false`.
198
+ */
168
199
  readonly iterate: (
169
200
  ownerId: OwnerIdBytes,
170
201
  begin: NonNegativeInt,
@@ -175,19 +206,14 @@ export interface Storage {
175
206
  /**
176
207
  * Validates the {@link OwnerWriteKey} for the given {@link Owner}.
177
208
  *
178
- * Returns `true` if the write key is valid, `false` otherwise.
209
+ * Returns `true` if the write key is valid, `false` otherwise. A relay logs a
210
+ * throw and answers the request with {@link ProtocolWriteError}.
179
211
  */
180
212
  readonly validateWriteKey: (
181
213
  ownerId: OwnerIdBytes,
182
214
  writeKey: OwnerWriteKey,
183
215
  ) => boolean;
184
216
 
185
- /** Sets the {@link OwnerWriteKey} for the given {@link Owner}. */
186
- readonly setWriteKey: (
187
- ownerId: OwnerIdBytes,
188
- writeKey: OwnerWriteKey,
189
- ) => void;
190
-
191
217
  /**
192
218
  * Write encrypted {@link CrdtMessage}s to storage.
193
219
  *
@@ -205,7 +231,12 @@ export interface Storage {
205
231
  messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
206
232
  ) => Task<void, StorageWriteMessagesError>;
207
233
 
208
- /** Read encrypted {@link DbChange}s from storage. */
234
+ /**
235
+ * Read encrypted {@link DbChange}s from storage.
236
+ *
237
+ * The returned array must not be modified or reused later, because sync
238
+ * references it until it finishes its answer.
239
+ */
209
240
  readonly readDbChange: (
210
241
  ownerId: OwnerIdBytes,
211
242
  timestamp: TimestampBytes,
@@ -233,7 +264,7 @@ export interface StorageQuotaError
233
264
  extends OwnerError, Typed<"StorageQuotaError"> {}
234
265
 
235
266
  /**
236
- * Expected reasons why {@link Storage.writeMessages} stored none of a batch.
267
+ * Reasons why {@link Storage.writeMessages} stored none of a batch.
237
268
  *
238
269
  * The built-in relay storage stores opaque encrypted messages and rejects
239
270
  * batches over quota. The built-in client storage decrypts and validates
@@ -243,16 +274,39 @@ export interface StorageQuotaError
243
274
  * never a reason to reject a batch. The contract permits quota checks on either
244
275
  * side.
245
276
  *
277
+ * Both built-in storages return {@link UnknownError} when SQLite fails the
278
+ * write, for example on a full disk, after rolling it back.
279
+ *
246
280
  * @group Core
247
281
  */
248
- export type StorageWriteMessagesError = StorageQuotaError;
282
+ export type StorageWriteMessagesError = StorageQuotaError | UnknownError;
249
283
 
250
284
  /**
251
- * A cryptographic hash used for efficiently comparing collections of
252
- * {@link TimestampBytes}es.
253
- *
254
- * It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
255
- * one or more timestamps.
285
+ * A summary of a range of {@link TimestampBytes} for comparing ranges cheaply.
286
+ *
287
+ * It is the XOR of the first {@link fingerprintSize} bytes of the SHA-256 hash
288
+ * of each timestamp in the range, or {@link zeroFingerprint} for an empty
289
+ * range.
290
+ *
291
+ * XOR is linear, so fingerprints are not collision resistant: any 97 timestamps
292
+ * contain a nonempty subset whose fingerprints XOR to zero. Anyone who can
293
+ * write to a relay for an owner, which includes every collaborator of a
294
+ * {@link SharedOwner}, can store timestamps whose fingerprints XOR to the
295
+ * fingerprint of a chosen change. Two ranges that differ only by that change
296
+ * and those timestamps then compare equal, so sync skips them, and the change
297
+ * does not move between that relay and a peer. This was demonstrated end to
298
+ * end, and nothing reports it. {@link Evolu.requestSync} does not heal it,
299
+ * because the next round compares the same fingerprints. In protocol version 1,
300
+ * the only recovery is syncing through a relay that does not hold those
301
+ * timestamps. Binding the count or hashing the result changes every
302
+ * fingerprint, so it needs a new protocol version.
303
+ *
304
+ * A write-key holder can also store a change under a timestamp another device
305
+ * has not used yet, such as a later one with that device's NodeId, because
306
+ * relays accept timestamps from any time. A relay keeps the first change stored
307
+ * for a timestamp, so it does not store the device's change when it arrives,
308
+ * and peers that sync through that relay later get the stored one instead. A
309
+ * new fingerprint does not close this.
256
310
  *
257
311
  * @group Ranges
258
312
  */
@@ -374,6 +428,9 @@ export interface EncryptedCrdtMessage {
374
428
  /**
375
429
  * Encrypted DbChange
376
430
  *
431
+ * A 24-byte XChaCha20-Poly1305 nonce, the ciphertext length, and the ciphertext
432
+ * ending with its 16-byte Poly1305 tag, so at least 41 bytes.
433
+ *
377
434
  * @group Messages
378
435
  */
379
436
  export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
@@ -512,7 +569,7 @@ export interface DbChange extends InferType<typeof DbChange> {}
512
569
  */
513
570
  export interface BaseSqliteStorage extends Omit<
514
571
  Storage,
515
- "validateWriteKey" | "setWriteKey" | "writeMessages" | "readDbChange"
572
+ "validateWriteKey" | "writeMessages" | "readDbChange"
516
573
  > {
517
574
  /**
518
575
  * Inserts a timestamp for an owner into the skiplist-based storage.
@@ -584,6 +641,10 @@ export const createBaseSqliteStorage = (
584
641
  // into concatBytes.
585
642
  const concatenatedTimestamps = concatByteArrays(timestampsBytes);
586
643
 
644
+ // A batch has no known size, so unlike the CTEs bounded by
645
+ // skiplistMaxLevel, the planner would make the owner's timestamps the outer
646
+ // loop. A cross join keeps the order as written, so each timestamp is
647
+ // looked up by primary key.
587
648
  const result = deps.sqlite.exec<{
588
649
  timestampBytes: TimestampBytes;
589
650
  }>(sql`
@@ -602,7 +663,7 @@ export const createBaseSqliteStorage = (
602
663
  select s.timestampBytes
603
664
  from
604
665
  split_timestamps s
605
- join evolu_timestamp t
666
+ cross join evolu_timestamp t
606
667
  on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
607
668
  `);
608
669
 
@@ -659,6 +720,9 @@ export const createBaseSqliteStorage = (
659
720
  deps.sqlite.exec(sql`
660
721
  delete from evolu_timestamp where ownerId = ${ownerId};
661
722
  `);
723
+ deps.sqlite.exec(sql`
724
+ delete from evolu_usage where ownerId = ${ownerId};
725
+ `);
662
726
  },
663
727
  });
664
728
 
@@ -1549,6 +1613,8 @@ const fingerprintRanges =
1549
1613
  ): ReadonlyArray<FingerprintRange> => {
1550
1614
  const bucketsJson = JSON.stringify(buckets);
1551
1615
 
1616
+ // A bucket past the owner's timestamps would descend below level 1
1617
+ // forever, so the walk stops there and the bucket has no row.
1552
1618
  const result = deps.sqlite.exec<{
1553
1619
  b: TimestampBytes | null;
1554
1620
  h1: Int64String;
@@ -1618,7 +1684,7 @@ const fingerprintRanges =
1618
1684
  c1
1619
1685
  left join evolu_timestamp as node
1620
1686
  on not c1.b and node.ownerId = ${ownerId} and node.t = c1.nt
1621
- where iif(c1.b, 1, c1.ic != c1.c)
1687
+ where iif(c1.b, 1, c1.ic != c1.c) and c1.dl >= 1
1622
1688
  ),
1623
1689
  c2(h1, h2, t, rn) as (
1624
1690
  select
@@ -1650,6 +1716,7 @@ const fingerprintRanges =
1650
1716
  select b, cast(h1 as text) as h1, cast(h2 as text) as h2
1651
1717
  from c3;
1652
1718
  `);
1719
+ assert(result.rows.length === buckets.length, "bucket out of range");
1653
1720
 
1654
1721
  const fingerprintRanges = result.rows.map(
1655
1722
  (row, i, arr): FingerprintRange => ({
@@ -1671,11 +1738,15 @@ const x = (a: string, b: string) => sql.raw(`(${a} | ${b}) - (${a} & ${b})`);
1671
1738
  /**
1672
1739
  * Reads the timestamp at a position within an owner's ordered timestamps.
1673
1740
  *
1741
+ * Throws when `index` is not below the owner's size.
1742
+ *
1674
1743
  * @group SQLite
1675
1744
  */
1676
1745
  export const getTimestampByIndex =
1677
1746
  (deps: SqliteDep) =>
1678
1747
  (ownerId: OwnerIdBytes, index: NonNegativeInt): TimestampBytes => {
1748
+ // An index past the owner's timestamps would descend below level 1
1749
+ // forever, so the walk stops there and finds no row.
1679
1750
  const result = deps.sqlite.exec<{
1680
1751
  readonly pt: TimestampBytes;
1681
1752
  }>(sql.prepared`
@@ -1743,14 +1814,16 @@ export const getTimestampByIndex =
1743
1814
  )
1744
1815
  )
1745
1816
  from fi
1746
- where ic != ${index + 1}
1817
+ where ic != ${index + 1} and cl >= 1
1747
1818
  )
1748
1819
  select pt
1749
1820
  from fi
1750
1821
  where ic == ${index + 1};
1751
1822
  `);
1752
1823
 
1753
- return result.rows[0].pt;
1824
+ const row = result.rows.at(0);
1825
+ assert(row, "index out of range");
1826
+ return row.pt;
1754
1827
  };
1755
1828
 
1756
1829
  /**