@evolu/common 8.0.0-next.2 → 8.0.0-next.4

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 (73) hide show
  1. package/dist/src/Assert.d.ts +5 -5
  2. package/dist/src/Assert.d.ts.map +1 -1
  3. package/dist/src/Assert.js +4 -4
  4. package/dist/src/Identicon.js +4 -4
  5. package/dist/src/LockManager.d.ts +74 -0
  6. package/dist/src/LockManager.d.ts.map +1 -0
  7. package/dist/src/LockManager.js +103 -0
  8. package/dist/src/Microtask.d.ts.map +1 -1
  9. package/dist/src/Microtask.js +90 -26
  10. package/dist/src/Object.d.ts.map +1 -1
  11. package/dist/src/RefCount.d.ts.map +1 -1
  12. package/dist/src/RefCount.js +153 -68
  13. package/dist/src/Resource.d.ts +6 -6
  14. package/dist/src/Resource.d.ts.map +1 -1
  15. package/dist/src/Resource.js +211 -162
  16. package/dist/src/Schedule.d.ts +5 -5
  17. package/dist/src/Schedule.d.ts.map +1 -1
  18. package/dist/src/Schedule.js +53 -44
  19. package/dist/src/Sqlite.d.ts +2 -0
  20. package/dist/src/Sqlite.d.ts.map +1 -1
  21. package/dist/src/Sqlite.js +64 -40
  22. package/dist/src/Task.d.ts +6 -33
  23. package/dist/src/Task.d.ts.map +1 -1
  24. package/dist/src/Task.js +213 -204
  25. package/dist/src/Test.d.ts +4 -4
  26. package/dist/src/Time.d.ts +5 -0
  27. package/dist/src/Time.d.ts.map +1 -1
  28. package/dist/src/Time.js +5 -0
  29. package/dist/src/Type.d.ts +7 -0
  30. package/dist/src/Type.d.ts.map +1 -1
  31. package/dist/src/Type.js +7 -0
  32. package/dist/src/WebSocket.d.ts.map +1 -1
  33. package/dist/src/WebSocket.js +7 -6
  34. package/dist/src/Worker.d.ts +42 -8
  35. package/dist/src/Worker.d.ts.map +1 -1
  36. package/dist/src/Worker.js +298 -75
  37. package/dist/src/index.d.ts +1 -0
  38. package/dist/src/index.d.ts.map +1 -1
  39. package/dist/src/index.js +1 -0
  40. package/dist/src/local-first/Db.d.ts +5 -4
  41. package/dist/src/local-first/Db.d.ts.map +1 -1
  42. package/dist/src/local-first/Db.js +44 -38
  43. package/dist/src/local-first/Evolu.d.ts +3 -2
  44. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  45. package/dist/src/local-first/Evolu.js +90 -80
  46. package/dist/src/local-first/Owner.d.ts.map +1 -1
  47. package/dist/src/local-first/Shared.d.ts +65 -54
  48. package/dist/src/local-first/Shared.d.ts.map +1 -1
  49. package/dist/src/local-first/Shared.js +387 -373
  50. package/package.json +1 -1
  51. package/src/Array.ts +8 -8
  52. package/src/Assert.ts +5 -5
  53. package/src/Identicon.ts +4 -4
  54. package/src/LockManager.ts +181 -0
  55. package/src/Microtask.ts +17 -11
  56. package/src/Object.ts +3 -3
  57. package/src/RefCount.ts +25 -21
  58. package/src/Resource.ts +63 -56
  59. package/src/Schedule.ts +70 -48
  60. package/src/Set.ts +4 -4
  61. package/src/Sqlite.ts +41 -30
  62. package/src/Task.ts +77 -120
  63. package/src/Test.ts +4 -4
  64. package/src/Time.ts +7 -0
  65. package/src/Type.ts +9 -0
  66. package/src/WebSocket.ts +7 -6
  67. package/src/Worker.ts +240 -49
  68. package/src/index.ts +1 -0
  69. package/src/local-first/Db.ts +69 -46
  70. package/src/local-first/Evolu.ts +74 -68
  71. package/src/local-first/Owner.ts +1 -1
  72. package/src/local-first/Query.ts +1 -1
  73. package/src/local-first/Shared.ts +472 -464
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolu/common",
3
- "version": "8.0.0-next.2",
3
+ "version": "8.0.0-next.4",
4
4
  "description": "TypeScript library and local-first platform",
5
5
  "keywords": [
6
6
  "evolu",
package/src/Array.ts CHANGED
@@ -326,7 +326,7 @@ export function mapArray<T, U>(
326
326
  const length = array.length;
327
327
  const result = new Array<U>(length);
328
328
  for (let i = 0; i < length; i++) result[i] = mapper(array[i], i);
329
- return result as ReadonlyArray<U>;
329
+ return result;
330
330
  }
331
331
 
332
332
  /**
@@ -394,7 +394,7 @@ export function flatMapArray<T, U>(
394
394
  index: number,
395
395
  ) => ReadonlyArray<U> | Array<U>,
396
396
  ): ReadonlyArray<U> {
397
- return array.flatMap(mapper) as ReadonlyArray<U>;
397
+ return array.flatMap(mapper);
398
398
  }
399
399
 
400
400
  /**
@@ -475,7 +475,7 @@ export function filterArray<T>(
475
475
  array: ReadonlyArray<T>,
476
476
  predicate: PredicateWithIndex<T>,
477
477
  ): ReadonlyArray<T> {
478
- return array.filter(predicate) as ReadonlyArray<T>;
478
+ return array.filter(predicate);
479
479
  }
480
480
 
481
481
  /**
@@ -519,7 +519,7 @@ export function dedupeArray<T>(
519
519
  by?: (item: T) => unknown,
520
520
  ): ReadonlyArray<T> {
521
521
  if (by == null) {
522
- return Array.from(new Set(array)) as ReadonlyArray<T>;
522
+ return Array.from(new Set(array));
523
523
  }
524
524
 
525
525
  const seen = new Set<unknown>();
@@ -528,7 +528,7 @@ export function dedupeArray<T>(
528
528
  if (seen.has(key)) return false;
529
529
  seen.add(key);
530
530
  return true;
531
- }) as ReadonlyArray<T>;
531
+ });
532
532
  }
533
533
 
534
534
  /**
@@ -593,7 +593,7 @@ export function partitionArray<T>(
593
593
  }
594
594
  }
595
595
 
596
- return [trueArray as ReadonlyArray<T>, falseArray as ReadonlyArray<T>];
596
+ return [trueArray, falseArray];
597
597
  }
598
598
 
599
599
  /**
@@ -622,7 +622,7 @@ export function sortArray<T>(
622
622
  array: ReadonlyArray<T>,
623
623
  compareFn?: (a: T, b: T) => number,
624
624
  ): ReadonlyArray<T> {
625
- return array.toSorted(compareFn) as ReadonlyArray<T>;
625
+ return array.toSorted(compareFn);
626
626
  }
627
627
 
628
628
  /**
@@ -644,7 +644,7 @@ export function reverseArray<T>(
644
644
  /** Possibly empty array. */
645
645
  export function reverseArray<T>(array: ReadonlyArray<T>): ReadonlyArray<T>;
646
646
  export function reverseArray<T>(array: ReadonlyArray<T>): ReadonlyArray<T> {
647
- return array.toReversed() as ReadonlyArray<T>;
647
+ return array.toReversed();
648
648
  }
649
649
 
650
650
  /**
package/src/Assert.ts CHANGED
@@ -146,14 +146,14 @@ export function assertNotAborted<T, E>(
146
146
  * ### Example
147
147
  *
148
148
  * ```ts
149
- * const stack = new globalThis.AsyncDisposableStack();
150
- * assertNotDisposed(stack); // no-op
151
- * await stack.disposeAsync();
152
- * assertNotDisposed(stack); // throws Error
149
+ * using disposer = new globalThis.AsyncDisposableStack();
150
+ * assertNotDisposed(disposer); // no-op
151
+ * await disposer.disposeAsync();
152
+ * assertNotDisposed(disposer); // throws Error
153
153
  * ```
154
154
  */
155
155
  export const assertNotDisposed = (
156
- value: globalThis.DisposableStack | globalThis.AsyncDisposableStack,
156
+ value: DisposableStack | AsyncDisposableStack,
157
157
  ): void => {
158
158
  assert(!value.disposed, "Expected value to not be disposed.");
159
159
  };
package/src/Identicon.ts CHANGED
@@ -147,8 +147,8 @@ export const createIdenticon = (
147
147
  case "sutnar": {
148
148
  // Three compositional variants with adaptive colors.
149
149
  const hue = (bytes[0] / 255) * 360;
150
- const saturation = 50 + (bytes[1] / 255) * 30;
151
- const lightness = 50 + (bytes[2] / 255) * 20;
150
+ const saturation = 60 + (bytes[1] / 255) * 30;
151
+ const lightness = 35 + (bytes[2] / 255) * 20;
152
152
 
153
153
  // Generate palette from base hue with variations
154
154
  const toHsl = (h: number, s: number, l: number) =>
@@ -157,11 +157,11 @@ export const createIdenticon = (
157
157
  const color1 = toHsl(hue, saturation, lightness);
158
158
  const color2 = toHsl((hue + 120) % 360, saturation, lightness);
159
159
  const color3 = toHsl((hue + 240) % 360, saturation, lightness);
160
- const color4 = toHsl(hue, saturation * 0.3, lightness * 0.5);
160
+ const color4 = toHsl(hue, saturation * 0.5, lightness * 0.6);
161
161
  const color5 = toHsl(
162
162
  hue,
163
163
  saturation * 0.5,
164
- Math.min(lightness * 1.3, 90),
164
+ Math.min(lightness * 1.3, 60),
165
165
  );
166
166
 
167
167
  const palette = [color1, color2, color3, color4, color5] as const;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * {@link LockManager} helpers.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { createRandomBytes } from "./Crypto.js";
8
+ import { lazyVoid } from "./Function.js";
9
+ import { tryAsync } from "./Result.js";
10
+ import { AbortError, type Task } from "./Task.js";
11
+ import { createId } from "./Type.js";
12
+ import type { Callback } from "./Types.js";
13
+
14
+ /**
15
+ * Dependency wrapper for {@link LockManager}.
16
+ *
17
+ * For React Native, use the `lockManager` ponyfill from `@evolu/react-native`.
18
+ * For tests, use {@link testCreateLockManager}.
19
+ *
20
+ * ### Example
21
+ *
22
+ * ```ts
23
+ * // Web or Node.js 24+
24
+ * import { testCreateLockManager } from "@evolu/common";
25
+ *
26
+ * const deps: LockManagerDep = {
27
+ * lockManager: testCreateLockManager(),
28
+ * };
29
+ * ```
30
+ *
31
+ * ### Example
32
+ *
33
+ * ```ts
34
+ * // React Native
35
+ * import { lockManager } from "@evolu/react-native";
36
+ *
37
+ * const deps: LockManagerDep = { lockManager };
38
+ * ```
39
+ */
40
+
41
+ export interface LockManagerDep {
42
+ readonly lockManager: LockManager;
43
+ }
44
+
45
+ /**
46
+ * Creates a test {@link LockManager} backed by the native platform
47
+ * {@link LockManager}.
48
+ *
49
+ * Native backing is intentional: tests must observe the same locking behavior
50
+ * as the platform runtime, and a userland implementation cannot reproduce it.
51
+ *
52
+ * The platform `LockManager` cannot be instantiated, so tests cannot create an
53
+ * isolated native lock per case. This helper isolates lock usage per instance
54
+ * via internal namespacing, so tests can reuse the same lock names without
55
+ * contending through the global Web Locks. Query results are filtered to that
56
+ * private namespace and returned with the original visible names.
57
+ */
58
+ export const testCreateLockManager = (
59
+ nativeLockManager: LockManager = globalThis.navigator.locks,
60
+ ): LockManager => {
61
+ const namespace = createId({ randomBytes: createRandomBytes() });
62
+
63
+ const filterLockInfosByNamespace = <T extends LockInfo>(
64
+ locks: ReadonlyArray<T>,
65
+ ): Array<T> =>
66
+ locks.flatMap((lock) =>
67
+ lock.name?.endsWith(namespace) === true
68
+ ? [{ ...lock, name: lock.name.slice(0, -namespace.length) }]
69
+ : [],
70
+ );
71
+
72
+ return {
73
+ request: <T>(
74
+ name: string,
75
+ optionsOrCallback: LockOptions | LockGrantedCallback<T>,
76
+ maybeCallback?: LockGrantedCallback<T>,
77
+ ): Promise<Awaited<T>> => {
78
+ const [options, callback] =
79
+ typeof optionsOrCallback === "function"
80
+ ? [{}, optionsOrCallback]
81
+ : [optionsOrCallback, maybeCallback!];
82
+
83
+ return nativeLockManager.request(`${name}${namespace}`, options, (lock) =>
84
+ callback(lock && { mode: lock.mode, name }),
85
+ );
86
+ },
87
+
88
+ query: async () => {
89
+ const locks = await nativeLockManager.query();
90
+ return {
91
+ ...(locks.held && {
92
+ held: filterLockInfosByNamespace(locks.held),
93
+ }),
94
+ ...(locks.pending && {
95
+ pending: filterLockInfosByNamespace(locks.pending),
96
+ }),
97
+ };
98
+ },
99
+ };
100
+ };
101
+
102
+ /**
103
+ * Competes to become the current leader for `name` using {@link LockManager}.
104
+ *
105
+ * Only one caller can lead for a given name at a time, and the returned
106
+ * {@link AsyncDisposable} represents that leadership.
107
+ *
108
+ * Leadership is held until the returned handle is disposed. Once released,
109
+ * another waiting caller may become the next leader. Waiting for leadership is
110
+ * abortable via the calling {@link Task}'s signal.
111
+ */
112
+ export const acquireLeaderLock =
113
+ (name: string): Task<AsyncDisposable, AbortError, LockManagerDep> =>
114
+ (run) =>
115
+ tryAsync(
116
+ async () => {
117
+ const acquisition = Promise.withResolvers<void>();
118
+ const released = Promise.withResolvers<void>();
119
+
120
+ const request = run.deps.lockManager.request(
121
+ createLeaderLockName(name),
122
+ { mode: "exclusive", signal: run.signal },
123
+ async () => {
124
+ acquisition.resolve();
125
+ await released.promise;
126
+ },
127
+ );
128
+ void request.catch(acquisition.reject);
129
+
130
+ await acquisition.promise;
131
+
132
+ return {
133
+ [Symbol.asyncDispose]: async () => {
134
+ released.resolve();
135
+ await request;
136
+ },
137
+ };
138
+ },
139
+ (error): AbortError =>
140
+ AbortError.is(error) ? error : { type: "AbortError", reason: error },
141
+ );
142
+
143
+ /**
144
+ * Competes to become the current leader for `name` using {@link LockManager}.
145
+ *
146
+ * Only one caller can lead for a given name at a time. The callback runs when
147
+ * leadership is acquired, and the returned {@link Disposable} represents that
148
+ * leadership request.
149
+ *
150
+ * Leadership is held until the returned handle is disposed. Once released,
151
+ * another waiting caller may become the next leader. Waiting for leadership is
152
+ * abortable by disposing the returned handle.
153
+ */
154
+ export const acquireLeaderLockCallback =
155
+ (deps: LockManagerDep) =>
156
+ (name: string, callback: Callback<void>): Disposable => {
157
+ const abortController = new AbortController();
158
+ let release: (() => void) | null = null;
159
+
160
+ const request = deps.lockManager.request(
161
+ createLeaderLockName(name),
162
+ { mode: "exclusive", signal: abortController.signal },
163
+ async () => {
164
+ const released = Promise.withResolvers<void>();
165
+ release = released.resolve;
166
+ callback();
167
+ await released.promise;
168
+ },
169
+ );
170
+ void request.catch(lazyVoid);
171
+
172
+ return {
173
+ [Symbol.dispose]: () => {
174
+ abortController.abort();
175
+ release?.();
176
+ },
177
+ };
178
+ };
179
+
180
+ const createLeaderLockName = (name: string): string =>
181
+ `evolu-leaderlock-${name}`;
package/src/Microtask.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { appendToArray, type NonEmptyReadonlyArray } from "./Array.js";
8
+ import { assertNotDisposed } from "./Assert.js";
8
9
 
9
10
  /**
10
11
  * Batches values and flushes them in a single microtask.
@@ -24,11 +25,17 @@ export interface MicrotaskBatch<T> extends Disposable {
24
25
  export const createMicrotaskBatch = <T>(
25
26
  onFlush: (items: NonEmptyReadonlyArray<T>) => void,
26
27
  ): MicrotaskBatch<T> => {
28
+ using disposer = new DisposableStack();
27
29
  let queue: NonEmptyReadonlyArray<T> | null = null;
28
- let disposed = false;
29
30
 
30
- const flushNow = () => {
31
- if (disposed || queue == null) return;
31
+ disposer.defer(() => {
32
+ queue = null;
33
+ });
34
+
35
+ const disposables = disposer.move();
36
+
37
+ const flushQueuedItems = () => {
38
+ if (queue == null) return;
32
39
  const queuedItems = queue;
33
40
  queue = null;
34
41
  onFlush(queuedItems);
@@ -36,23 +43,22 @@ export const createMicrotaskBatch = <T>(
36
43
 
37
44
  return {
38
45
  push: (item) => {
39
- if (disposed) return;
46
+ assertNotDisposed(disposables);
40
47
 
41
48
  if (queue == null) {
42
49
  queue = [item];
43
- queueMicrotask(flushNow);
50
+ queueMicrotask(flushQueuedItems);
44
51
  return;
45
52
  }
46
53
 
47
54
  queue = appendToArray(queue, item);
48
55
  },
49
56
 
50
- flushNow,
51
-
52
- [Symbol.dispose]: () => {
53
- if (disposed) return;
54
- disposed = true;
55
- queue = null;
57
+ flushNow: () => {
58
+ assertNotDisposed(disposables);
59
+ flushQueuedItems();
56
60
  },
61
+
62
+ [Symbol.dispose]: () => disposables.dispose(),
57
63
  };
58
64
  };
package/src/Object.ts CHANGED
@@ -89,7 +89,7 @@ export const objectToEntries = <T extends Record<string, any>>(
89
89
  ): ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]> =>
90
90
  Object.entries(record) as Array<
91
91
  [StringKeyOf<T>, T[StringKeyOf<T>]]
92
- > as ReadonlyArray<[StringKeyOf<T>, T[StringKeyOf<T>]]>;
92
+ >;
93
93
 
94
94
  // A helper type to remove symbol keys (e.g for branded objects).
95
95
  type StringKeyOf<T> = Extract<keyof T, string>;
@@ -148,10 +148,10 @@ export const mapObject = <K extends string, V, U>(
148
148
  const out = Object.create(null) as Record<K, U>;
149
149
 
150
150
  for (const key in record) {
151
- out[key as K] = fn(record[key as K], key as K);
151
+ out[key as K] = fn(record[key as K], key);
152
152
  }
153
153
 
154
- return out as ReadonlyRecord<K, U>;
154
+ return out;
155
155
  };
156
156
 
157
157
  /** Conditionally excludes a property from an object. */
package/src/RefCount.ts CHANGED
@@ -34,34 +34,32 @@ export interface RefCount extends Disposable {
34
34
 
35
35
  /** Creates {@link RefCount}. */
36
36
  export const createRefCount = (): RefCount => {
37
+ // We use DisposableStack only because of assertNotDisposed.
38
+ using disposer = new DisposableStack();
37
39
  let count = zeroNonNegativeInt;
38
- const stack = new DisposableStack();
39
- stack.defer(() => {
40
- count = zeroNonNegativeInt;
41
- });
42
- const moved = stack.move();
40
+ const disposables = disposer.move();
43
41
 
44
42
  return {
45
43
  increment: () => {
46
- assertNotDisposed(moved);
44
+ assertNotDisposed(disposables);
47
45
  const nextCount = PositiveInt.orThrow(count + 1);
48
46
  count = nextCount;
49
47
  return nextCount;
50
48
  },
51
49
 
52
50
  decrement: () => {
53
- assertNotDisposed(moved);
51
+ assertNotDisposed(disposables);
54
52
  assert(count > 0, "RefCount must not be decremented below zero.");
55
53
  count = NonNegativeInt.orThrow(count - 1);
56
54
  return count;
57
55
  },
58
56
 
59
57
  getCount: () => {
60
- assertNotDisposed(moved);
58
+ assertNotDisposed(disposables);
61
59
  return count;
62
60
  },
63
61
 
64
- [Symbol.dispose]: () => moved.dispose(),
62
+ [Symbol.dispose]: () => disposables.dispose(),
65
63
  };
66
64
  };
67
65
 
@@ -112,17 +110,23 @@ export function createRefCountByKey<TKey, L>(
112
110
  export function createRefCountByKey<TKey, L = TKey>({
113
111
  lookup = identity as Lookup<TKey, L>,
114
112
  }: CreateRefCountByKeyOptions<TKey, L> = {}): RefCountByKey<TKey> {
115
- const stack = new DisposableStack();
113
+ // We use DisposableStack only because of assertNotDisposed.
114
+ using disposer = new DisposableStack();
116
115
 
117
- const refCountByKey = stack.adopt(
116
+ const refCountByKey = disposer.adopt(
118
117
  createLookupMap<TKey, RefCount, L>({ lookup }),
119
118
  (refCountByKey) => {
120
- for (const refCount of refCountByKey.values()) refCount[Symbol.dispose]();
121
- refCountByKey.clear();
119
+ using disposer = new DisposableStack();
120
+ disposer.defer(() => {
121
+ refCountByKey.clear();
122
+ });
123
+ for (const refCount of refCountByKey.values()) {
124
+ disposer.use(refCount);
125
+ }
122
126
  },
123
127
  );
124
128
 
125
- const moved = stack.move();
129
+ const disposables = disposer.move();
126
130
 
127
131
  const getRefCount = (key: TKey): RefCount => {
128
132
  let refCount = refCountByKey.get(key);
@@ -135,36 +139,36 @@ export function createRefCountByKey<TKey, L = TKey>({
135
139
 
136
140
  return {
137
141
  increment: (key) => {
138
- assertNotDisposed(moved);
142
+ assertNotDisposed(disposables);
139
143
  return getRefCount(key).increment();
140
144
  },
141
145
 
142
146
  decrement: (key) => {
143
- assertNotDisposed(moved);
147
+ assertNotDisposed(disposables);
144
148
  const refCount = getRefCount(key);
145
149
  const nextCount = refCount.decrement();
146
150
  if (nextCount === 0) {
147
- refCount[Symbol.dispose]();
148
151
  refCountByKey.delete(key);
152
+ refCount[Symbol.dispose]();
149
153
  }
150
154
  return nextCount;
151
155
  },
152
156
 
153
157
  getCount: (key) => {
154
- assertNotDisposed(moved);
158
+ assertNotDisposed(disposables);
155
159
  return refCountByKey.get(key)?.getCount() ?? zeroNonNegativeInt;
156
160
  },
157
161
 
158
162
  has: (key) => {
159
- assertNotDisposed(moved);
163
+ assertNotDisposed(disposables);
160
164
  return refCountByKey.has(key);
161
165
  },
162
166
 
163
167
  keys: () => {
164
- assertNotDisposed(moved);
168
+ assertNotDisposed(disposables);
165
169
  return new Set(refCountByKey.keys());
166
170
  },
167
171
 
168
- [Symbol.dispose]: () => moved.dispose(),
172
+ [Symbol.dispose]: () => disposables.dispose(),
169
173
  };
170
174
  }