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

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/LICENSE +21 -0
  2. package/dist/src/Lookup.d.ts +2 -0
  3. package/dist/src/Lookup.d.ts.map +1 -1
  4. package/dist/src/Lookup.js +8 -0
  5. package/dist/src/Polyfills.d.ts +7 -5
  6. package/dist/src/Polyfills.d.ts.map +1 -1
  7. package/dist/src/Polyfills.js +35 -5
  8. package/dist/src/Task.d.ts +11 -0
  9. package/dist/src/Task.d.ts.map +1 -1
  10. package/dist/src/Task.js +11 -0
  11. package/dist/src/Test.d.ts +28 -0
  12. package/dist/src/Test.d.ts.map +1 -1
  13. package/dist/src/Test.js +31 -0
  14. package/dist/src/Time.d.ts +4 -3
  15. package/dist/src/Time.d.ts.map +1 -1
  16. package/dist/src/Time.js +14 -7
  17. package/dist/src/Type.d.ts +6 -1
  18. package/dist/src/Type.d.ts.map +1 -1
  19. package/dist/src/Type.js +8 -0
  20. package/dist/src/WebSocket.d.ts +21 -4
  21. package/dist/src/WebSocket.d.ts.map +1 -1
  22. package/dist/src/WebSocket.js +85 -16
  23. package/dist/src/local-first/Db.d.ts +2 -2
  24. package/dist/src/local-first/Db.d.ts.map +1 -1
  25. package/dist/src/local-first/Db.js +18 -19
  26. package/dist/src/local-first/Protocol.d.ts +2 -2
  27. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  28. package/dist/src/local-first/Relay.d.ts +18 -13
  29. package/dist/src/local-first/Relay.d.ts.map +1 -1
  30. package/dist/src/local-first/Relay.js +11 -17
  31. package/dist/src/local-first/Shared.d.ts +1 -1
  32. package/dist/src/local-first/Shared.d.ts.map +1 -1
  33. package/dist/src/local-first/Storage.d.ts +6 -7
  34. package/dist/src/local-first/Storage.d.ts.map +1 -1
  35. package/dist/src/local-first/Storage.js +6 -6
  36. package/package.json +15 -12
  37. package/src/Lookup.ts +14 -0
  38. package/src/Polyfills.ts +60 -5
  39. package/src/Task.ts +11 -0
  40. package/src/Test.ts +41 -1
  41. package/src/Time.ts +15 -8
  42. package/src/Type.ts +15 -0
  43. package/src/WebSocket.ts +125 -21
  44. package/src/local-first/Db.ts +20 -24
  45. package/src/local-first/Protocol.ts +2 -5
  46. package/src/local-first/Relay.ts +46 -39
  47. package/src/local-first/Shared.ts +2 -2
  48. package/src/local-first/Storage.ts +11 -12
package/src/WebSocket.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * @module
5
5
  */
6
6
 
7
+ import { assert } from "./Assert.js";
7
8
  import { lazyTrue } from "./Function.js";
8
9
  import type { Result } from "./Result.js";
9
10
  import { err, ok } from "./Result.js";
@@ -12,8 +13,7 @@ import { exponential, jitter, maxDelay } from "./Schedule.js";
12
13
  import type { RetryError, Task } from "./Task.js";
13
14
  import { callback, retry } from "./Task.js";
14
15
  import type { Millis } from "./Time.js";
15
- import { String, type Typed } from "./Type.js";
16
- import { assert } from "./Assert.js";
16
+ import { ArrayBuffer, String, Uint8Array, type Typed } from "./Type.js";
17
17
 
18
18
  /**
19
19
  * WebSocket with auto-reconnect.
@@ -27,7 +27,9 @@ import { assert } from "./Assert.js";
27
27
  *
28
28
  * Created via {@link createWebSocket} which returns a {@link Task}.
29
29
  *
30
- * Disposing the WebSocket closes the connection.
30
+ * Disposing starts closing the connection without waiting for the close event
31
+ * so disposal stays immediate. This wrapper treats disposal as local teardown,
32
+ * not as waiting for the full WebSocket close handshake to finish.
31
33
  *
32
34
  * ## How Binary Messages Work
33
35
  *
@@ -69,7 +71,7 @@ export interface WebSocket extends AsyncDisposable {
69
71
  * error if the data couldn't be sent.
70
72
  */
71
73
  send: (
72
- data: string | ArrayBufferLike | Blob | ArrayBufferView,
74
+ data: BufferSource | Blob | string | globalThis.Uint8Array,
73
75
  ) => Result<void, WebSocketSendError>;
74
76
 
75
77
  readonly getReadyState: () => WebSocketReadyState;
@@ -201,7 +203,6 @@ export const createWebSocket: CreateWebSocket =
201
203
  await using stack = new AsyncDisposableStack();
202
204
 
203
205
  let socket: globalThis.WebSocket | null = null;
204
- let disposed = false;
205
206
 
206
207
  const closeSocket = () => {
207
208
  if (!socket) return;
@@ -284,36 +285,54 @@ export const createWebSocket: CreateWebSocket =
284
285
  if (!socket || socket.readyState === socket.CONNECTING) {
285
286
  return err({ type: "WebSocketSendError" });
286
287
  }
287
- socket.send(data);
288
+ socket.send(ensureSendableData(data));
288
289
  return ok();
289
290
  },
290
291
 
291
292
  getReadyState: () => {
292
- if (disposed) return "closed";
293
+ if (moved.disposed) return "closed";
293
294
  return socket ? nativeToStringState[socket.readyState] : "connecting";
294
295
  },
295
296
 
296
297
  isOpen: () =>
297
- !disposed && socket?.readyState === globalThis.WebSocket.OPEN,
298
+ !moved.disposed && socket?.readyState === globalThis.WebSocket.OPEN,
298
299
 
299
- [Symbol.asyncDispose]: async () => {
300
- disposed = true;
301
- await moved.disposeAsync();
302
- },
300
+ [Symbol.asyncDispose]: () => moved.disposeAsync(),
303
301
  });
304
302
  };
305
303
 
306
- /** Creates a deterministic in-memory {@link CreateWebSocket} for testing. */
304
+ /** Clones SharedArrayBuffer-backed Uint8Array values before WebSocket.send. */
305
+ const ensureSendableData = (
306
+ data: BufferSource | Blob | string | globalThis.Uint8Array,
307
+ ): BufferSource | Blob | string => {
308
+ if (!Uint8Array.is(data)) return data;
309
+ return ArrayBuffer.is(data.buffer)
310
+ ? (data as globalThis.Uint8Array<ArrayBuffer>)
311
+ : new globalThis.Uint8Array(data);
312
+ };
313
+
314
+ const nativeToStringState: Record<number, WebSocketReadyState> = {
315
+ [globalThis.WebSocket.CONNECTING]: "connecting",
316
+ [globalThis.WebSocket.OPEN]: "open",
317
+ [globalThis.WebSocket.CLOSING]: "closing",
318
+ [globalThis.WebSocket.CLOSED]: "closed",
319
+ };
320
+
321
+ /**
322
+ * An inspectable in-memory {@link CreateWebSocket} for testing by
323
+ * {@link testCreateWebSocket}.
324
+ */
307
325
  export interface TestCreateWebSocket extends CreateWebSocket {
308
326
  readonly createdUrls: Array<string>;
309
327
  readonly sentMessages: Array<{
310
328
  readonly url: string;
311
- readonly data: string | ArrayBufferLike | Blob | ArrayBufferView;
329
+ readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
312
330
  }>;
313
331
  readonly message: (url: string, data: string | ArrayBuffer | Blob) => void;
314
332
  readonly open: (url: string) => void;
315
333
  }
316
334
 
335
+ /** Creates {@link TestCreateWebSocket}. */
317
336
  export const testCreateWebSocket = (
318
337
  options: {
319
338
  /** Throw immediately when a socket is created. */
@@ -326,7 +345,7 @@ export const testCreateWebSocket = (
326
345
  const createdUrls: Array<string> = [];
327
346
  const sentMessages: Array<{
328
347
  readonly url: string;
329
- readonly data: string | ArrayBufferLike | Blob | ArrayBufferView;
348
+ readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
330
349
  }> = [];
331
350
  const stateByUrl = new Map<
332
351
  string,
@@ -361,7 +380,10 @@ export const testCreateWebSocket = (
361
380
  if (state.isDisposed || !state.isOpen) {
362
381
  return err({ type: "WebSocketSendError" });
363
382
  }
364
- sentMessages.push({ url, data });
383
+ sentMessages.push({
384
+ url,
385
+ data: ensureSendableData(data),
386
+ });
365
387
  return ok();
366
388
  },
367
389
 
@@ -399,9 +421,91 @@ export const testCreateWebSocket = (
399
421
  });
400
422
  };
401
423
 
402
- const nativeToStringState: Record<number, WebSocketReadyState> = {
403
- [globalThis.WebSocket.CONNECTING]: "connecting",
404
- [globalThis.WebSocket.OPEN]: "open",
405
- [globalThis.WebSocket.CLOSING]: "closing",
406
- [globalThis.WebSocket.CLOSED]: "closed",
424
+ /**
425
+ * A native {@link WebSocket} prepared for integration tests by
426
+ * {@link testSetupWebSocket}.
427
+ */
428
+ export interface TestSetupWebSocket extends AsyncDisposable {
429
+ readonly socket: globalThis.WebSocket;
430
+ readonly send: (
431
+ data: BufferSource | Blob | string | globalThis.Uint8Array,
432
+ ) => void;
433
+ readonly waitForMessage: () => Promise<string | globalThis.Uint8Array>;
434
+ }
435
+
436
+ /** Opens a native {@link WebSocket} and returns {@link TestSetupWebSocket}. */
437
+ export const testSetupWebSocket = async (
438
+ url: string,
439
+ ): Promise<TestSetupWebSocket> => {
440
+ const socket = new globalThis.WebSocket(url);
441
+ socket.binaryType = "arraybuffer";
442
+
443
+ await new Promise<void>((resolve, reject) => {
444
+ const onOpen = () => {
445
+ cleanup();
446
+ resolve();
447
+ };
448
+
449
+ const onError = () => {
450
+ cleanup();
451
+ socket.close();
452
+ reject(new Error("WebSocket connection failed"));
453
+ };
454
+
455
+ const cleanup = () => {
456
+ socket.removeEventListener("open", onOpen);
457
+ socket.removeEventListener("error", onError);
458
+ };
459
+
460
+ socket.addEventListener("open", onOpen, { once: true });
461
+ socket.addEventListener("error", onError, { once: true });
462
+ });
463
+
464
+ return {
465
+ socket,
466
+ send: (data) => {
467
+ socket.send(ensureSendableData(data));
468
+ },
469
+ waitForMessage: () =>
470
+ new Promise((resolve, reject) => {
471
+ if (socket.readyState === globalThis.WebSocket.CLOSED) {
472
+ reject(new Error("WebSocket closed before message"));
473
+ return;
474
+ }
475
+
476
+ const onMessage = (event: MessageEvent) => {
477
+ cleanup();
478
+
479
+ if (typeof event.data === "string") {
480
+ resolve(event.data);
481
+ return;
482
+ }
483
+
484
+ resolve(new globalThis.Uint8Array(event.data as ArrayBuffer));
485
+ };
486
+
487
+ const onClose = () => {
488
+ cleanup();
489
+ reject(new Error("WebSocket closed before message"));
490
+ };
491
+
492
+ const cleanup = () => {
493
+ socket.removeEventListener("message", onMessage);
494
+ socket.removeEventListener("close", onClose);
495
+ };
496
+
497
+ socket.addEventListener("message", onMessage, { once: true });
498
+ socket.addEventListener("close", onClose, { once: true });
499
+ }),
500
+ [Symbol.asyncDispose]: async () => {
501
+ if (socket.readyState === globalThis.WebSocket.CLOSED) return;
502
+
503
+ const closed = new Promise<void>((resolve) => {
504
+ socket.addEventListener("close", () => resolve(), { once: true });
505
+ });
506
+
507
+ socket.close();
508
+ await closed;
509
+ },
510
+ };
407
511
  };
@@ -10,7 +10,7 @@ import {
10
10
  type NonEmptyArray,
11
11
  type NonEmptyReadonlyArray,
12
12
  } from "../Array.js";
13
- import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
13
+ import { assert, assertNonEmptyReadonlyArray, assertType } from "../Assert.js";
14
14
  import type { ConsoleLevel } from "../Console.js";
15
15
  import {
16
16
  EncryptionKey,
@@ -77,8 +77,8 @@ import {
77
77
  createBaseSqliteStorage,
78
78
  createBaseSqliteStorageTables,
79
79
  DbChange,
80
- getOwnerUsage,
81
80
  getTimestampInsertStrategy,
81
+ readOwnerUsageOrDefault,
82
82
  updateOwnerUsage,
83
83
  type BaseSqliteStorage,
84
84
  type BaseSqliteStorageDep,
@@ -120,10 +120,7 @@ export interface CreateDbWorkerDep {
120
120
  readonly createDbWorker: CreateDbWorker;
121
121
  }
122
122
 
123
- export type DbWorkerDeps = WorkerDeps &
124
- LeaderLockDep &
125
- CreateSqliteDriverDep &
126
- RandomBytesDep;
123
+ export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep;
127
124
 
128
125
  export const startDbWorker =
129
126
  (self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
@@ -207,10 +204,14 @@ export const startDbWorker =
207
204
  const runWithStorage = run.addDeps({ storage });
208
205
 
209
206
  /**
210
- * SharedWorker repeats sends until it gets a response, so handling here
211
- * must be idempotent and ignore already processed IDs.
207
+ * SharedWorker retries until some leader replies, so this worker must
208
+ * ignore callback IDs it already completed.
212
209
  *
213
- * TODO: Bound memory growth by evicting old IDs.
210
+ * The Set intentionally lives for one leader's lifetime. If that leader tab
211
+ * closes, retries go to a new leader with a fresh Set. Revisit this only if
212
+ * memory pressure shows up; even millions of short callback IDs are
213
+ * acceptable here, and naive eviction could let a still-retried request run
214
+ * again.
214
215
  */
215
216
  const processedRequestIds = new Set<Id>();
216
217
 
@@ -285,15 +286,12 @@ export const startDbWorker =
285
286
  >();
286
287
 
287
288
  for (const owner of request.message.owners) {
288
- storage.setOwnerState(owner.encryptionKey);
289
+ storage.setRequestContext(owner.encryptionKey);
289
290
  const protocolMessage = createProtocolMessageForSync({
290
291
  storage,
291
292
  console,
292
293
  })(owner.id, SubscriptionFlags.Subscribe);
293
-
294
- if (protocolMessage) {
295
- protocolMessagesByOwnerId.set(owner.id, protocolMessage);
296
- }
294
+ protocolMessagesByOwnerId.set(owner.id, protocolMessage);
297
295
  }
298
296
 
299
297
  postQueuedResponse({
@@ -310,7 +308,7 @@ export const startDbWorker =
310
308
  const { owner, inputMessage } = request.message;
311
309
 
312
310
  runWithStorage<void, never>(async (run) => {
313
- storage.setOwnerState(owner.encryptionKey);
311
+ storage.setRequestContext(owner.encryptionKey);
314
312
 
315
313
  const result = await run(
316
314
  applyProtocolMessageAsClient(inputMessage, {
@@ -611,7 +609,7 @@ const applyColumnChange =
611
609
  * implementation, and switch owner encryption keys between requests.
612
610
  */
613
611
  interface ClientStorage extends Storage, BaseSqliteStorage {
614
- readonly setOwnerState: (encryptionKey: EncryptionKey) => void;
612
+ readonly setRequestContext: (encryptionKey: EncryptionKey) => void;
615
613
  readonly didWriteMessages: () => boolean;
616
614
  }
617
615
 
@@ -653,7 +651,7 @@ const createClientStorage =
653
651
  // same file.
654
652
  // This is safe because the worker handles one message at a time. We will
655
653
  // refactor it later, we will probably have to change Protocol API.
656
- setOwnerState: (nextEncryptionKey) => {
654
+ setRequestContext: (nextEncryptionKey) => {
657
655
  encryptionKey = nextEncryptionKey;
658
656
  didWriteMessages = false;
659
657
  },
@@ -743,9 +741,8 @@ const createClientStorage =
743
741
  isInsert = false;
744
742
  break;
745
743
  case "isDeleted":
746
- if (SqliteBoolean.is(r.value)) {
747
- isDelete = sqliteBooleanToBoolean(r.value);
748
- }
744
+ assertType(SqliteBoolean, r.value);
745
+ isDelete = sqliteBooleanToBoolean(r.value);
749
746
  break;
750
747
  default:
751
748
  values[r.column] = r.value;
@@ -843,7 +840,7 @@ const applyLocalOnlyChange =
843
840
  if (change.isDelete) {
844
841
  deps.sqlite.exec(sql`
845
842
  delete from ${sql.identifier(change.table)}
846
- where id = ${change.id};
843
+ where "ownerId" = ${change.ownerId} and "id" = ${change.id};
847
844
  `);
848
845
  } else {
849
846
  const ownerId = change.ownerId;
@@ -866,13 +863,12 @@ const applyMessages =
866
863
  (ownerId: OwnerId, messages: NonEmptyReadonlyArray<CrdtMessage>): void => {
867
864
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
868
865
 
869
- const usage = getOwnerUsage(deps)(
866
+ const usage = readOwnerUsageOrDefault(deps)(
870
867
  ownerIdBytes,
871
868
  timestampToTimestampBytes(firstInArray(messages).timestamp),
872
869
  );
873
- if (!usage.ok) return;
874
870
 
875
- let { firstTimestamp, lastTimestamp } = usage.value;
871
+ let { firstTimestamp, lastTimestamp } = usage;
876
872
 
877
873
  for (const { timestamp, change } of messages) {
878
874
  const columns = dbChangeToColumns(change, timestamp.millis);
@@ -181,7 +181,6 @@ import { Packr } from "msgpackr";
181
181
  import { isNonEmptyArray, type NonEmptyReadonlyArray } from "../Array.js";
182
182
  import { assert } from "../Assert.js";
183
183
  import type { Brand } from "../Brand.js";
184
- import type { ConsoleDep } from "../Console.js";
185
184
  import {
186
185
  type Buffer,
187
186
  bytesToHex,
@@ -190,6 +189,7 @@ import {
190
189
  hexToBytes,
191
190
  utf8ToBytes,
192
191
  } from "../Buffer.js";
192
+ import type { ConsoleDep } from "../Console.js";
193
193
  import {
194
194
  createPadmePadding,
195
195
  decryptWithXChaCha20Poly1305,
@@ -548,10 +548,7 @@ export const createProtocolMessageFromCrdtMessages =
548
548
  /** Creates a {@link ProtocolMessage} for sync. */
549
549
  export const createProtocolMessageForSync =
550
550
  (deps: StorageDep & ConsoleDep) =>
551
- (
552
- ownerId: OwnerId,
553
- subscriptionFlag?: SubscriptionFlag,
554
- ): ProtocolMessage | null => {
551
+ (ownerId: OwnerId, subscriptionFlag?: SubscriptionFlag): ProtocolMessage => {
555
552
  const buffer = createProtocolMessageBuffer(ownerId, {
556
553
  messageType: MessageType.Request,
557
554
  subscriptionFlag: subscriptionFlag ?? SubscriptionFlags.None,
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import {
8
+ dedupeArray,
8
9
  filterArray,
9
10
  firstInArray,
10
11
  isNonEmptyArray,
@@ -12,11 +13,11 @@ import {
12
13
  } from "../Array.js";
13
14
  import { assert } from "../Assert.js";
14
15
  import type { TimingSafeEqualDep } from "../Crypto.js";
15
- import { err, getOk, ok } from "../Result.js";
16
+ import { err, ok } from "../Result.js";
16
17
  import type { SqliteDep } from "../Sqlite.js";
17
18
  import { sql } from "../Sqlite.js";
18
19
  import { createMutexByKey } from "../Task.js";
19
- import { Name, PositiveInt } from "../Type.js";
20
+ import { Name, PositiveInt, uint8ArrayToBase64Url } from "../Type.js";
20
21
  import { isPromiseLike, type Awaitable } from "../Types.js";
21
22
  import {
22
23
  OwnerId,
@@ -33,8 +34,8 @@ import type {
33
34
  } from "./Storage.js";
34
35
  import {
35
36
  createBaseSqliteStorage,
36
- getOwnerUsage,
37
37
  getTimestampInsertStrategy,
38
+ readOwnerUsageOrDefault,
38
39
  updateOwnerUsage,
39
40
  } from "./Storage.js";
40
41
  import { timestampToTimestampBytes } from "./Timestamp.js";
@@ -52,8 +53,9 @@ export interface RelayConfig extends StorageConfig {
52
53
  * Optional callback to check if an {@link OwnerId} is allowed to access the
53
54
  * relay. If this callback is not provided, all owners are allowed.
54
55
  *
55
- * The callback receives the {@link OwnerId} and returns a {@link Awaitable}
56
- * boolean: `true` to allow access, or `false` to deny.
56
+ * The callback receives the {@link OwnerId} and an options object with an
57
+ * abort `AbortSignal`, and returns a {@link Awaitable} boolean: `true` to
58
+ * allow access, or `false` to deny.
57
59
  *
58
60
  * The callback can be synchronous (for SQLite or in-memory checks) or
59
61
  * asynchronous (for calling remote APIs).
@@ -68,9 +70,9 @@ export interface RelayConfig extends StorageConfig {
68
70
  *
69
71
  * Owners specify which relays to connect to via `OwnerTransport`. In
70
72
  * WebSocket-based implementations, this check occurs before accepting the
71
- * connection, with the OwnerId typically extracted from the URL Path (e.g.,
72
- * `ws://localhost:4000/<ownerId>`). The relay requires the URL to be in the
73
- * correct format for OwnerId extraction.
73
+ * connection, with the OwnerId typically extracted from the URL query string
74
+ * (e.g., `ws://localhost:4000?ownerId=...`). The relay requires the URL to be
75
+ * in the correct format for OwnerId extraction.
74
76
  *
75
77
  * ### Example
76
78
  *
@@ -87,23 +89,31 @@ export interface RelayConfig extends StorageConfig {
87
89
  *
88
90
  *
89
91
  * // Relay
90
- * isOwnerAllowed: (ownerId) =>
92
+ * isOwnerAllowed: (ownerId, { signal: _signal }) =>
91
93
  * Promise.resolve(ownerId === "6jy_2F4RT5qqeLgJ14_dnQ"),
92
94
  * ```
93
95
  */
94
- readonly isOwnerAllowed?: (ownerId: OwnerId) => Awaitable<boolean>;
96
+ readonly isOwnerAllowed?: (
97
+ ownerId: OwnerId,
98
+ options: {
99
+ /** Aborted when the relay stops waiting for the access check. */
100
+ readonly signal: AbortSignal;
101
+ },
102
+ ) => Awaitable<boolean>;
95
103
  }
96
104
 
97
105
  /**
98
- * A completely interchangeable server for syncing and backing up encrypted data
99
- * between Evolu clients.
106
+ * Sync and backup relay for Evolu clients.
100
107
  *
101
- * Unlike traditional servers, relays are blind by design—they transmit
102
- * encrypted data without understanding its shape or meaning. This enables true
103
- * decentralization and infinite horizontal scalability with minimal
104
- * infrastructure.
108
+ * A relay syncs and backs up encrypted data for Evolu apps. Evolu apps can use
109
+ * multiple relays at the same time, combining self-hosted and cloud relays for
110
+ * resilience. Relays are blind by design: they transmit and store encrypted
111
+ * data without understanding its shape or meaning.
105
112
  */
106
- export interface Relay extends AsyncDisposable {}
113
+ export interface Relay extends AsyncDisposable {
114
+ /** The TCP port actually bound by the relay. */
115
+ readonly port: number;
116
+ }
107
117
 
108
118
  export const createRelaySqliteStorage =
109
119
  (deps: SqliteStorageDeps & TimingSafeEqualDep) =>
@@ -158,25 +168,31 @@ export const createRelaySqliteStorage =
158
168
 
159
169
  writeMessages: (ownerIdBytes, messages) => async (run) => {
160
170
  const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
161
- const messagesWithTimestampBytes = mapArray(messages, (m) => ({
162
- timestamp: timestampToTimestampBytes(m.timestamp),
163
- change: m.change,
164
- }));
171
+ const uniqueMessagesWithTimestampBytes = dedupeArray(
172
+ mapArray(messages, (m) => ({
173
+ timestamp: timestampToTimestampBytes(m.timestamp),
174
+ change: m.change,
175
+ })),
176
+ (message) => uint8ArrayToBase64Url(message.timestamp),
177
+ );
165
178
 
166
- const result = await run(
179
+ return run(
167
180
  mutexByOwnerId.withLock(ownerId, async () => {
168
181
  const existingTimestampsResult =
169
182
  sqliteStorageBase.getExistingTimestamps(
170
183
  ownerIdBytes,
171
- mapArray(messagesWithTimestampBytes, (m) => m.timestamp),
184
+ mapArray(uniqueMessagesWithTimestampBytes, (m) => m.timestamp),
172
185
  );
173
186
 
174
- const existingTimestampsSet = new Set(
175
- existingTimestampsResult.map((t) => t.toString()),
187
+ const existingTimestampKeys = new Set(
188
+ mapArray(existingTimestampsResult, uint8ArrayToBase64Url),
176
189
  );
177
190
  const newMessages = filterArray(
178
- messagesWithTimestampBytes,
179
- (m) => !existingTimestampsSet.has(m.timestamp.toString()),
191
+ uniqueMessagesWithTimestampBytes,
192
+ (message) =>
193
+ !existingTimestampKeys.has(
194
+ uint8ArrayToBase64Url(message.timestamp),
195
+ ),
180
196
  );
181
197
 
182
198
  // Nothing to write
@@ -184,11 +200,9 @@ export const createRelaySqliteStorage =
184
200
  return ok();
185
201
  }
186
202
 
187
- const usage = getOk(
188
- getOwnerUsage(deps)(
189
- ownerIdBytes,
190
- firstInArray(newMessages).timestamp,
191
- ),
203
+ const usage = readOwnerUsageOrDefault(deps)(
204
+ ownerIdBytes,
205
+ firstInArray(newMessages).timestamp,
192
206
  );
193
207
 
194
208
  const incomingBytes = newMessages.reduce(
@@ -250,13 +264,6 @@ export const createRelaySqliteStorage =
250
264
  });
251
265
  }),
252
266
  );
253
-
254
- if (!result.ok) {
255
- if (result.error.type === "AbortError") return ok();
256
- return result;
257
- }
258
-
259
- return ok();
260
267
  },
261
268
 
262
269
  readDbChange: (ownerId, timestamp) => {
@@ -324,7 +324,7 @@ interface SharedEvolu extends AsyncDisposable {
324
324
 
325
325
  readonly requestApplySyncMessage: (
326
326
  ownerId: OwnerId,
327
- inputMessage: Uint8Array<ArrayBuffer>,
327
+ inputMessage: Uint8Array,
328
328
  ) => void;
329
329
  }
330
330
 
@@ -364,7 +364,7 @@ export interface DbWorkerInput {
364
364
  | {
365
365
  readonly type: "ApplySyncMessage";
366
366
  readonly owner: Owner;
367
- readonly inputMessage: Uint8Array<ArrayBuffer>;
367
+ readonly inputMessage: Uint8Array;
368
368
  };
369
369
  };
370
370
  }
@@ -12,7 +12,6 @@ import type { Brand } from "../Brand.js";
12
12
  import { concatBytes } from "../Buffer.js";
13
13
  import { decrement } from "../Number.js";
14
14
  import type { RandomDep } from "../Random.js";
15
- import type { Result } from "../Result.js";
16
15
  import { err, ok } from "../Result.js";
17
16
  import type { SqliteDep } from "../Sqlite.js";
18
17
  import { sql, SqliteValue } from "../Sqlite.js";
@@ -1569,17 +1568,17 @@ export const getTimestampByIndex =
1569
1568
  return result.rows[0].pt;
1570
1569
  };
1571
1570
 
1572
- /** Retrieves usage information for an owner from the evolu_usage table. */
1573
- export const getOwnerUsage =
1571
+ /** Reads owner usage from SQLite and returns default bounds when absent. */
1572
+ export const readOwnerUsageOrDefault =
1574
1573
  (deps: SqliteDep) =>
1575
1574
  (
1576
1575
  ownerIdBytes: OwnerIdBytes,
1577
1576
  initialTimestamp: TimestampBytes,
1578
- ): Result<{
1579
- storedBytes: NonNegativeInt | null;
1580
- firstTimestamp: TimestampBytes;
1581
- lastTimestamp: TimestampBytes;
1582
- }> => {
1577
+ ): {
1578
+ readonly storedBytes: NonNegativeInt | null;
1579
+ readonly firstTimestamp: TimestampBytes;
1580
+ readonly lastTimestamp: TimestampBytes;
1581
+ } => {
1583
1582
  const result = deps.sqlite.exec<{
1584
1583
  storedBytes: NonNegativeInt;
1585
1584
  firstTimestamp: TimestampBytes | null;
@@ -1591,22 +1590,22 @@ export const getOwnerUsage =
1591
1590
  `);
1592
1591
 
1593
1592
  if (!isNonEmptyArray(result.rows)) {
1594
- return ok({
1593
+ return {
1595
1594
  storedBytes: null,
1596
1595
  firstTimestamp: initialTimestamp,
1597
1596
  lastTimestamp: initialTimestamp,
1598
- });
1597
+ };
1599
1598
  }
1600
1599
 
1601
1600
  const row = firstInArray(result.rows);
1602
1601
  assert(row.firstTimestamp, "not null");
1603
1602
  assert(row.lastTimestamp, "not null");
1604
1603
 
1605
- return ok({
1604
+ return {
1606
1605
  storedBytes: row.storedBytes,
1607
1606
  firstTimestamp: row.firstTimestamp,
1608
1607
  lastTimestamp: row.lastTimestamp,
1609
- });
1608
+ };
1610
1609
  };
1611
1610
 
1612
1611
  /**