@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.
- package/dist/src/Bytes.d.ts +31 -14
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +48 -8
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +5 -1
- package/dist/src/Polyfills.d.ts.map +1 -1
- package/dist/src/Polyfills.js +3 -2
- package/dist/src/Sqlite.d.ts +1 -1
- package/dist/src/Task.d.ts +4 -3
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +4 -3
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/local-first/Db.d.ts +32 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +30 -9
- package/dist/src/local-first/Evolu.d.ts +21 -6
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +5 -1
- package/dist/src/local-first/Protocol.d.ts +215 -93
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +799 -489
- package/dist/src/local-first/Relay.d.ts +9 -1
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +41 -36
- package/dist/src/local-first/Shared.d.ts +79 -53
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +67 -42
- package/dist/src/local-first/Storage.d.ts +80 -18
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +20 -4
- package/package.json +1 -1
- package/src/Bytes.test.ts +212 -0
- package/src/Bytes.ts +67 -17
- package/src/Error.ts +5 -1
- package/src/Polyfills.ts +3 -2
- package/src/Sqlite.ts +1 -1
- package/src/Task.ts +4 -3
- package/src/index.ts +4 -1
- package/src/local-first/Db.ts +76 -17
- package/src/local-first/Evolu.test.ts +43 -12
- package/src/local-first/Evolu.ts +34 -7
- package/src/local-first/Protocol.test.ts +1541 -44
- package/src/local-first/Protocol.ts +1070 -605
- package/src/local-first/Relay.ts +80 -69
- package/src/local-first/Shared.test.ts +37 -1
- package/src/local-first/Shared.ts +124 -73
- 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 {
|
|
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
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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" | "
|
|
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
|
-
|
|
1824
|
+
const row = result.rows.at(0);
|
|
1825
|
+
assert(row, "index out of range");
|
|
1826
|
+
return row.pt;
|
|
1754
1827
|
};
|
|
1755
1828
|
|
|
1756
1829
|
/**
|