@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
package/src/Object.ts CHANGED
@@ -305,6 +305,169 @@ export const excludeProp = <T extends object, K extends keyof T>(
305
305
  return rest;
306
306
  };
307
307
 
308
+ /**
309
+ * Returns `next` with every part deep-equal to the same part of `previous`
310
+ * replaced by that part, or `previous` itself when the two are deep-equal.
311
+ *
312
+ * It keeps the identity of data that arrives as a new copy, such as a
313
+ * structured clone posted between workers, so unchanged parts can be compared
314
+ * with `===`, and a UI updates only what changed. Plain objects, arrays, and
315
+ * `Uint8Array`s are compared by their contents, and any other value by
316
+ * identity. The contents of a plain object are the properties an object spread
317
+ * copies. A changed object or array is returned as a new ordinary one. It walks
318
+ * the data as a tree, so a part referenced from several places is compared at
319
+ * each place, and a part that a cycle leads back to is returned as it is in
320
+ * `next`.
321
+ *
322
+ * An array item is compared with the previous item at its index, so removing or
323
+ * inserting an item compares every item after it with a different one. When
324
+ * `itemToKey` returns a key for an item, such as its ID, the item is compared
325
+ * with the previous item of the same key instead. Keys are compared as `Map`
326
+ * keys are, so each should be unique in its array; an item whose key several
327
+ * previous items share is compared with the last of them.
328
+ *
329
+ * ### Example
330
+ *
331
+ * ```ts
332
+ * import {
333
+ * assertNotSame,
334
+ * assertSame,
335
+ * isPlainObject,
336
+ * shareStructure,
337
+ * } from "@evolu/common";
338
+ *
339
+ * const previous = {
340
+ * user: { name: "Alice" },
341
+ * todos: [{ title: "Buy milk" }],
342
+ * };
343
+ *
344
+ * // An equal copy gives the previous value back.
345
+ * assertSame(
346
+ * shareStructure(previous, structuredClone(previous)),
347
+ * previous,
348
+ * );
349
+ *
350
+ * // A change keeps every unchanged part.
351
+ * const next = shareStructure(previous, {
352
+ * user: { name: "Alice" },
353
+ * todos: [{ title: "Buy bread" }],
354
+ * });
355
+ * assertNotSame(next, previous);
356
+ * assertSame(next.user, previous.user);
357
+ * assertNotSame(next.todos, previous.todos);
358
+ *
359
+ * // A key compares each todo with the previous todo of the same ID.
360
+ * const todos = [
361
+ * { id: 1, title: "Buy milk" },
362
+ * { id: 2, title: "Walk the dog" },
363
+ * ];
364
+ * const remaining = shareStructure(
365
+ * todos,
366
+ * [{ id: 2, title: "Walk the dog" }],
367
+ * (todo) => (isPlainObject(todo) ? todo.id : undefined),
368
+ * );
369
+ * assertSame(remaining[0], todos[1]);
370
+ * ```
371
+ */
372
+ export const shareStructure = <T>(
373
+ previous: T,
374
+ next: T,
375
+ itemToKey?: (item: unknown) => unknown,
376
+ ): T => {
377
+ // The arrays and plain objects of `next` being compared, each with whether a
378
+ // cycle led back to it.
379
+ const isCycledByPart = new Map<object, boolean>();
380
+
381
+ const share = (previous: unknown, next: unknown): unknown => {
382
+ if (Object.is(previous, next)) return previous;
383
+
384
+ if (Array.isArray(previous) && Array.isArray(next)) {
385
+ const previousItems: ReadonlyArray<unknown> = previous;
386
+ return visit(next, () => {
387
+ // Array.from visits holes, which map would skip or keep as holes.
388
+ const previousByKey =
389
+ itemToKey &&
390
+ new Map(Array.from(previousItems, (item) => [itemToKey(item), item]));
391
+ let isEqual = previousItems.length === next.length;
392
+ const shared = Array.from(next, (item: unknown, index) => {
393
+ const key = itemToKey?.(item);
394
+ const previousItem =
395
+ key === undefined ? previousItems[index] : previousByKey?.get(key);
396
+ const sharedItem = share(previousItem, item);
397
+ if (!Object.is(sharedItem, previousItems[index])) isEqual = false;
398
+ return sharedItem;
399
+ });
400
+ return isEqual ? previousItems : shared;
401
+ });
402
+ }
403
+
404
+ if (isPlainObject(previous) && isPlainObject(next))
405
+ return visit(next, () => {
406
+ const keys = ownEnumerableKeys(next);
407
+ let isEqual = keys.length === ownEnumerableKeys(previous).length;
408
+ // Object.fromEntries defines each key, so `__proto__` stays own data.
409
+ const shared = Object.fromEntries(
410
+ keys.map((key) => {
411
+ const hasPrevious = Object.prototype.propertyIsEnumerable.call(
412
+ previous,
413
+ key,
414
+ );
415
+ const previousValue = hasPrevious
416
+ ? (previous as Record<PropertyKey, unknown>)[key]
417
+ : undefined;
418
+ const value = share(
419
+ previousValue,
420
+ (next as Record<PropertyKey, unknown>)[key],
421
+ );
422
+ if (!hasPrevious || !Object.is(value, previousValue))
423
+ isEqual = false;
424
+ return [key, value];
425
+ }),
426
+ );
427
+ return isEqual ? previous : shared;
428
+ });
429
+
430
+ // An indexed loop compares bytes far faster than `every`.
431
+ if (previous instanceof Uint8Array && next instanceof Uint8Array) {
432
+ if (previous.length !== next.length) return next;
433
+ for (let index = 0; index < previous.length; index++)
434
+ if (previous[index] !== next[index]) return next;
435
+ return previous;
436
+ }
437
+
438
+ return next;
439
+ };
440
+
441
+ // A cycle refers back to the part of `next` itself, so the part it leads back
442
+ // to is returned as it is, which keeps the cycle intact.
443
+ const visit = (part: object, compare: () => unknown): unknown => {
444
+ if (isCycledByPart.has(part)) {
445
+ isCycledByPart.set(part, true);
446
+ return part;
447
+ }
448
+ isCycledByPart.set(part, false);
449
+ const shared = compare();
450
+ const isCycled = isCycledByPart.get(part);
451
+ isCycledByPart.delete(part);
452
+ return isCycled ? part : shared;
453
+ };
454
+
455
+ return share(previous, next) as T;
456
+ };
457
+
458
+ // The keys an object spread copies: own enumerable strings and symbols.
459
+ const ownEnumerableKeys = (object: object): ReadonlyArray<PropertyKey> => {
460
+ const symbols = Object.getOwnPropertySymbols(object);
461
+ return symbols.length === 0
462
+ ? Object.keys(object)
463
+ : [
464
+ ...Object.keys(object),
465
+ ...symbols.filter((symbol) =>
466
+ Object.prototype.propertyIsEnumerable.call(object, symbol),
467
+ ),
468
+ ];
469
+ };
470
+
308
471
  /**
309
472
  * Creates a mutable Record.
310
473
  *
@@ -77,7 +77,6 @@ const idleMutexSnapshot = {
77
77
  taken: 0,
78
78
  waiters: [],
79
79
  available: 1,
80
- isIdle: true,
81
80
  };
82
81
 
83
82
  describe("BorrowedResource", () => {
@@ -672,7 +671,6 @@ describe("SharedResource", () => {
672
671
  );
673
672
 
674
673
  assertEqual(sharedResource.snapshot(), {
675
- isIdle: true,
676
674
  leaseCount: 0,
677
675
  hasResource: false,
678
676
  idleDisposePending: false,
@@ -682,7 +680,6 @@ describe("SharedResource", () => {
682
680
  const lease = await run.ok(sharedResource.acquire);
683
681
 
684
682
  assertEqual(sharedResource.snapshot(), {
685
- isIdle: false,
686
683
  leaseCount: 1,
687
684
  hasResource: true,
688
685
  idleDisposePending: false,
@@ -692,7 +689,6 @@ describe("SharedResource", () => {
692
689
  lease.release();
693
690
 
694
691
  assertEqual(sharedResource.snapshot(), {
695
- isIdle: false,
696
692
  leaseCount: 0,
697
693
  hasResource: true,
698
694
  idleDisposePending: true,
@@ -713,7 +709,6 @@ describe("SharedResource", () => {
713
709
  lease.release();
714
710
 
715
711
  assertEqual(sharedResource.snapshot(), {
716
- isIdle: false,
717
712
  leaseCount: 0,
718
713
  hasResource: true,
719
714
  idleDisposePending: false,
@@ -723,14 +718,13 @@ describe("SharedResource", () => {
723
718
  taken: 1,
724
719
  waiters: [],
725
720
  available: 0,
726
- isIdle: false,
727
721
  },
728
722
  });
729
723
 
730
724
  await disposed;
731
725
  });
732
726
 
733
- it("reports not idle while creating the first resource", async () => {
727
+ it("reports the held mutex while creating the first resource", async () => {
734
728
  await using run = testCreateRun();
735
729
  const gate = createGate();
736
730
 
@@ -742,11 +736,22 @@ describe("SharedResource", () => {
742
736
  );
743
737
 
744
738
  const acquire = run.ok(sharedResource.acquire);
745
- const isIdleWhileCreating = sharedResource.snapshot().isIdle;
739
+ const snapshotWhileCreating = sharedResource.snapshot();
746
740
  gate.open();
747
741
  await acquire;
748
742
 
749
- assertFalse(isIdleWhileCreating);
743
+ assertEqual(snapshotWhileCreating, {
744
+ leaseCount: 0,
745
+ hasResource: false,
746
+ idleDisposePending: false,
747
+ mutex: {
748
+ policy: "fifo",
749
+ permits: 1,
750
+ taken: 1,
751
+ waiters: [],
752
+ available: 0,
753
+ },
754
+ });
750
755
  });
751
756
 
752
757
  it("reports mutex waiters during a contended acquire", async () => {
@@ -953,9 +958,12 @@ describe("SharedResource", () => {
953
958
  await sharedResource[Symbol.asyncDispose]();
954
959
 
955
960
  assertEqual(resources.getDisposeCount(), 1);
956
- const snapshot = sharedResource.snapshot();
957
- assertTrue(snapshot.isIdle);
958
- assertFalse(snapshot.idleDisposePending);
961
+ assertEqual(sharedResource.snapshot(), {
962
+ leaseCount: 0,
963
+ hasResource: false,
964
+ idleDisposePending: false,
965
+ mutex: idleMutexSnapshot,
966
+ });
959
967
  });
960
968
 
961
969
  it("awaits async resource disposal", async () => {
@@ -2774,7 +2782,6 @@ describe("SharedResourceByKey", () => {
2774
2782
  [
2775
2783
  "a",
2776
2784
  {
2777
- isIdle: false,
2778
2785
  leaseCount: 2,
2779
2786
  hasResource: true,
2780
2787
  idleDisposePending: false,
@@ -2784,7 +2791,6 @@ describe("SharedResourceByKey", () => {
2784
2791
  [
2785
2792
  "b",
2786
2793
  {
2787
- isIdle: false,
2788
2794
  leaseCount: 1,
2789
2795
  hasResource: true,
2790
2796
  idleDisposePending: false,
@@ -5412,7 +5418,6 @@ describe("SharedResourceByKeyWithClaims", () => {
5412
5418
  [
5413
5419
  "a",
5414
5420
  {
5415
- isIdle: false,
5416
5421
  leaseCount: 3,
5417
5422
  hasResource: true,
5418
5423
  idleDisposePending: false,
@@ -5422,7 +5427,6 @@ describe("SharedResourceByKeyWithClaims", () => {
5422
5427
  [
5423
5428
  "b",
5424
5429
  {
5425
- isIdle: false,
5426
5430
  leaseCount: 1,
5427
5431
  hasResource: true,
5428
5432
  idleDisposePending: false,
package/src/Resource.ts CHANGED
@@ -279,12 +279,6 @@ export interface SharedResource<T extends Resource> extends AsyncDisposable {
279
279
 
280
280
  /** Snapshot returned by {@link SharedResource.snapshot}. */
281
281
  export interface SharedResourceSnapshot {
282
- /**
283
- * Whether the resource has no current value, no leases, no pending idle
284
- * disposal, and no acquisition in progress.
285
- */
286
- readonly isIdle: boolean;
287
-
288
282
  /** Current active lease count. */
289
283
  readonly leaseCount: NonNegativeInt;
290
284
 
@@ -498,20 +492,12 @@ export const createSharedResource =
498
492
  return await run(callback(lease.resource, lease.created));
499
493
  },
500
494
 
501
- snapshot: () => {
502
- const mutexSnapshot = mutex.snapshot();
503
- return {
504
- isIdle:
505
- heldLeases.size === 0 &&
506
- !current &&
507
- !idleDisposeFiber &&
508
- mutexSnapshot.isIdle,
509
- leaseCount: NonNegativeInt.orThrow(heldLeases.size),
510
- hasResource: current !== undefined,
511
- idleDisposePending: idleDisposeFiber !== undefined,
512
- mutex: mutexSnapshot,
513
- };
514
- },
495
+ snapshot: () => ({
496
+ leaseCount: NonNegativeInt.orThrow(heldLeases.size),
497
+ hasResource: current !== undefined,
498
+ idleDisposePending: idleDisposeFiber !== undefined,
499
+ mutex: mutex.snapshot(),
500
+ }),
515
501
 
516
502
  [Symbol.asyncDispose]: () => sharedResourceRun[Symbol.asyncDispose](),
517
503
  };
package/src/Sqlite.ts CHANGED
@@ -375,6 +375,13 @@ export const createSqlite =
375
375
  console.debug("begin");
376
376
  driver.exec(sql`begin;`);
377
377
 
378
+ // After errors such as SQLITE_FULL or SQLITE_IOERR, SQLite may roll
379
+ // back the transaction itself, and this rollback then fails with
380
+ // "no transaction is active". Disposal wraps both in a
381
+ // SuppressedError whose `suppressed` holds the original error. The
382
+ // data is consistent and nothing is lost.
383
+ // TODO: Rethink this when Evolu detects SQLITE_FULL, which needs
384
+ // the original error, not the failed rollback.
378
385
  using rollback = new DisposableStack();
379
386
  let shouldRollback = true;
380
387
  rollback.defer(() => {