@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
@@ -13,7 +13,8 @@ import {
13
13
  } from "../Array.ts";
14
14
  import { assert } from "../Assert.ts";
15
15
  import type { TimingSafeEqualDep } from "../Crypto.ts";
16
- import { err, ok } from "../Result.ts";
16
+ import { createUnknownError } from "../Error.ts";
17
+ import { err, ok, trySync } from "../Result.ts";
17
18
  import type { SqliteDep } from "../Sqlite.ts";
18
19
  import { sql } from "../Sqlite.ts";
19
20
  import { createMutexByKey } from "../Task.ts";
@@ -25,12 +26,13 @@ import {
25
26
  // OwnerTransport,
26
27
  OwnerWriteKey,
27
28
  } from "./Owner.ts";
29
+ import type { ProtocolWriteKeyError } from "./Protocol.ts";
28
30
  import type {
29
31
  EncryptedDbChange,
30
32
  SqliteStorageDeps,
31
33
  Storage,
32
34
  StorageConfig,
33
- StorageQuotaError,
35
+ StorageWriteMessagesError,
34
36
  } from "./Storage.ts";
35
37
  import {
36
38
  createBaseSqliteStorage,
@@ -66,7 +68,15 @@ export interface RelayConfig extends StorageConfig {
66
68
  *
67
69
  * OwnerId is used rather than short-lived tokens because this only controls
68
70
  * relay access, not write permissions. Since all data is encrypted on the
69
- * relay, OwnerId exposure is safe.
71
+ * relay, OwnerId exposure reveals no data.
72
+ *
73
+ * It does allow write-key squatting. The relay stores the first write key
74
+ * presented for an owner, so anyone who learns an OwnerId before the owner's
75
+ * own write key reaches the relay can claim it. The owner then gets
76
+ * {@link ProtocolWriteKeyError} on this relay until the operator deletes the
77
+ * owner's row in `evolu_writeKey`. This callback does not prevent that: it
78
+ * receives only the OwnerId of a connection, which a squatter can name as
79
+ * well, and every message on the connection can name another owner.
70
80
  *
71
81
  * Owners specify which relays to connect to via `OwnerTransport`. In
72
82
  * WebSocket-based implementations, this check occurs before accepting the
@@ -186,6 +196,9 @@ export const createRelaySqliteStorage =
186
196
  return deps.timingSafeEqual(rows[0].writeKey, writeKey);
187
197
  }
188
198
 
199
+ // When SQLite fails this insert, for example on a full disk, the throw
200
+ // reaches the protocol, which logs it and answers with WriteError,
201
+ // because a boolean has no room for the error.
189
202
  deps.sqlite.exec(sql`
190
203
  insert into evolu_writeKey (ownerId, writeKey)
191
204
  values (${ownerId}, ${writeKey});
@@ -194,15 +207,6 @@ export const createRelaySqliteStorage =
194
207
  return true;
195
208
  },
196
209
 
197
- setWriteKey: (ownerId, writeKey) => {
198
- deps.sqlite.exec(sql`
199
- insert into evolu_writeKey (ownerId, writeKey)
200
- values (${ownerId}, ${writeKey})
201
- on conflict (ownerId) do update
202
- set writeKey = excluded.writeKey;
203
- `);
204
- },
205
-
206
210
  writeMessages: (ownerIdBytes, messages) => async (run) => {
207
211
  const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
208
212
  const uniqueMessagesWithTimestampBytes = dedupeArray(
@@ -215,32 +219,42 @@ export const createRelaySqliteStorage =
215
219
 
216
220
  return run(
217
221
  mutexByOwnerId.withLock(ownerId, async () => {
218
- const existingTimestampsResult =
219
- sqliteStorageBase.getExistingTimestamps(
220
- ownerIdBytes,
221
- mapArray(uniqueMessagesWithTimestampBytes, (m) => m.timestamp),
222
+ // SQLite can fail these reads, for example on a corrupt page, and
223
+ // a throw would panic the relay's shared Run.
224
+ const readResult = trySync(() => {
225
+ const existingTimestampsResult =
226
+ sqliteStorageBase.getExistingTimestamps(
227
+ ownerIdBytes,
228
+ mapArray(
229
+ uniqueMessagesWithTimestampBytes,
230
+ (m) => m.timestamp,
231
+ ),
232
+ );
233
+
234
+ const existingTimestampKeys = new Set(
235
+ mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
236
+ );
237
+ const newMessages = filterArray(
238
+ uniqueMessagesWithTimestampBytes,
239
+ (message) =>
240
+ !existingTimestampKeys.has(
241
+ uint8ArrayToBase64Url(message.timestamp),
242
+ ),
222
243
  );
223
244
 
224
- const existingTimestampKeys = new Set(
225
- mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
226
- );
227
- const newMessages = filterArray(
228
- uniqueMessagesWithTimestampBytes,
229
- (message) =>
230
- !existingTimestampKeys.has(
231
- uint8ArrayToBase64Url(message.timestamp),
232
- ),
233
- );
245
+ // Nothing to write
246
+ if (!isNonEmptyArray(newMessages)) return null;
234
247
 
235
- // Nothing to write
236
- if (!isNonEmptyArray(newMessages)) {
237
- return ok();
238
- }
248
+ const usage = readOwnerUsageOrDefault(deps)(
249
+ ownerIdBytes,
250
+ firstInArray(newMessages).timestamp,
251
+ );
239
252
 
240
- const usage = readOwnerUsageOrDefault(deps)(
241
- ownerIdBytes,
242
- firstInArray(newMessages).timestamp,
243
- );
253
+ return { newMessages, usage };
254
+ }, createUnknownError);
255
+ if (!readResult.ok) return readResult;
256
+ if (readResult.value === null) return ok();
257
+ const { newMessages, usage } = readResult.value;
244
258
 
245
259
  const incomingBytes = newMessages.reduce(
246
260
  (sum, m) => sum + m.change.length,
@@ -260,7 +274,7 @@ export const createRelaySqliteStorage =
260
274
  ? await quotaResult
261
275
  : quotaResult;
262
276
  if (!isWithinQuota) {
263
- return err<StorageQuotaError>({
277
+ return err<StorageWriteMessagesError>({
264
278
  type: "StorageQuotaError",
265
279
  ownerId,
266
280
  });
@@ -268,39 +282,42 @@ export const createRelaySqliteStorage =
268
282
 
269
283
  let { firstTimestamp, lastTimestamp } = usage;
270
284
 
271
- return deps.sqlite.transaction(() => {
272
- for (const { timestamp, change } of newMessages) {
273
- let strategy;
274
- [strategy, firstTimestamp, lastTimestamp] =
275
- getTimestampInsertStrategy(
285
+ // SQLite can fail the write, for example on a full disk. The
286
+ // transaction has rolled back, and a throw would panic the relay's
287
+ // shared Run.
288
+ return trySync(() => {
289
+ deps.sqlite.transaction(() => {
290
+ for (const { timestamp, change } of newMessages) {
291
+ let strategy;
292
+ [strategy, firstTimestamp, lastTimestamp] =
293
+ getTimestampInsertStrategy(
294
+ timestamp,
295
+ firstTimestamp,
296
+ lastTimestamp,
297
+ );
298
+
299
+ sqliteStorageBase.insertTimestamp(
300
+ ownerIdBytes,
276
301
  timestamp,
277
- firstTimestamp,
278
- lastTimestamp,
302
+ strategy,
279
303
  );
280
304
 
281
- sqliteStorageBase.insertTimestamp(
305
+ deps.sqlite.exec(sql`
306
+ insert into evolu_message
307
+ ("ownerId", "timestamp", "change")
308
+ values (${ownerIdBytes}, ${timestamp}, ${change})
309
+ on conflict do nothing;
310
+ `);
311
+ }
312
+
313
+ updateOwnerUsage(deps)(
282
314
  ownerIdBytes,
283
- timestamp,
284
- strategy,
315
+ newStoredBytes,
316
+ firstTimestamp,
317
+ lastTimestamp,
285
318
  );
286
-
287
- deps.sqlite.exec(sql`
288
- insert into evolu_message
289
- ("ownerId", "timestamp", "change")
290
- values (${ownerIdBytes}, ${timestamp}, ${change})
291
- on conflict do nothing;
292
- `);
293
- }
294
-
295
- updateOwnerUsage(deps)(
296
- ownerIdBytes,
297
- newStoredBytes,
298
- firstTimestamp,
299
- lastTimestamp,
300
- );
301
-
302
- return ok();
303
- });
319
+ });
320
+ }, createUnknownError);
304
321
  }),
305
322
  );
306
323
  },
@@ -329,13 +346,7 @@ export const createRelaySqliteStorage =
329
346
  delete from evolu_message where ownerId = ${ownerId};
330
347
  `);
331
348
 
332
- deps.sqlite.exec(sql`
333
- delete from evolu_usage where ownerId = ${ownerId};
334
- `);
335
-
336
349
  sqliteStorageBase.deleteOwner(ownerId);
337
-
338
- return ok();
339
350
  });
340
351
  },
341
352
  };
@@ -21,7 +21,11 @@ import type { ConsoleEntry, ConsoleLevel } from "../Console.ts";
21
21
  import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.ts";
22
22
  import { createUnknownError, UnknownError } from "../Error.ts";
23
23
  import { isPlainObject } from "../Object.ts";
24
- import type { DbWorkerInit, UnsupportedDbVersionError } from "./Db.ts";
24
+ import type {
25
+ DatabaseHeldError,
26
+ DbWorkerInit,
27
+ UnsupportedDbVersionError,
28
+ } from "./Db.ts";
25
29
  import {
26
30
  createAppOwner,
27
31
  createOwnerSecret,
@@ -11442,4 +11446,36 @@ describe("startup refusal", () => {
11442
11446
  await testWaitForWorkerMessage();
11443
11447
  assertEqual(leaderInputs, [{ type: "Dispose" }]);
11444
11448
  });
11449
+
11450
+ const heldRefusal: DatabaseHeldError = {
11451
+ type: "DatabaseHeldError",
11452
+ name: testName,
11453
+ };
11454
+
11455
+ for (const error of [heldRefusal, refusal])
11456
+ it(`disposes a worker that acquired leadership before the ${error.type} refusal of a worker requested earlier arrived`, async () => {
11457
+ await using setup = await setupSharedWorker();
11458
+ await using disposer = new AsyncDisposableStack();
11459
+ const instance = await setup.createEvoluBeforeDbWorkerLeader();
11460
+ const initDbWorker = await setupTabLeader(setup, disposer);
11461
+ using leaderPort = testCreateMessagePort<DbWorkerOutput, DbWorkerInput>(
11462
+ initDbWorker.port,
11463
+ );
11464
+ const leaderInputs: Array<DbWorkerInput> = [];
11465
+ leaderPort.onMessage = (input) => {
11466
+ leaderInputs.push(input);
11467
+ };
11468
+
11469
+ // Browsers deliver the refusal first, because the refusing worker posts
11470
+ // it while it still holds the database lock the other one waits for.
11471
+ // The tenant does not depend on that order.
11472
+ leaderPort.postMessage({
11473
+ type: "LeaderAcquired",
11474
+ clock: createTimestamp(),
11475
+ });
11476
+ await testWaitForWorkerMessage();
11477
+ instance.dbWorkerPort.postMessage({ type: "LeaderRefused", error });
11478
+ await testWaitForWorkerMessage();
11479
+ assertEqual(leaderInputs, [{ type: "Dispose" }]);
11480
+ });
11445
11481
  });