@evolu/common 6.0.1-preview.20 → 6.0.1-preview.21

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.
@@ -1,4 +1,3 @@
1
- /* eslint-disable jsdoc/no-undefined-types */
2
1
  /**
3
2
  * Evolu Protocol
4
3
  *
@@ -21,7 +20,7 @@
21
20
  * transfer, ownership, real-time broadcasting, request-response semantics, and
22
21
  * error handling.
23
22
  *
24
- * ### Message Structure
23
+ * ### Message structure
25
24
  *
26
25
  * | Field | Notes |
27
26
  * | :----------------------------- | :------------------------ |
@@ -44,7 +43,7 @@
44
43
  * | - {@link NonNegativeInt} | Number of ranges. |
45
44
  * | - {@link Range} | |
46
45
  *
47
- * ### WriteKey Validation
46
+ * ### WriteKey validation
48
47
  *
49
48
  * The initiator sends a hasWriteKey flag and optionally a WriteKey. The
50
49
  * WriteKey is required when sending messages as a secure token proving the
@@ -80,33 +79,33 @@
80
79
  * initiator. In relay-to-relay or P2P sync, both sides may require the
81
80
  * {@link OwnerWriteKey} depending on who is the initiator.
82
81
  *
83
- * ### Protocol Errors
82
+ * ### Protocol errors
84
83
  *
85
84
  * The protocol uses error codes in the header to signal issues:
86
85
  *
87
86
  * - {@link ProtocolWriteKeyError}: The provided WriteKey is invalid or missing.
88
- * - {@link ProtocolWriteError}: A write operation failed (e.g., due to storage
89
- * limits or billing).
90
- * - {@link ProtocolSyncError}: A generic or unexpected synchronization failure
87
+ * - {@link ProtocolWriteError}: A serious relay-side write failure occurred.
88
+ * - {@link ProtocolSyncError}: A serious relay-side synchronization failure
91
89
  * occurred.
90
+ * - {@link ProtocolQuotaExceededError}: Storage or billing quota exceeded.
92
91
  * - {@link ProtocolUnsupportedVersionError}: Protocol version mismatch.
93
92
  * - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
94
93
  *
95
94
  * All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
96
95
  * to allow clients to associate errors with the correct owner.
97
96
  *
98
- * ### Message Size Limit
97
+ * ### Message size limit
99
98
  *
100
99
  * The protocol enforces a strict maximum size for all messages, defined by
101
- * {@link maxProtocolMessageSize}. This ensures every {@link ProtocolMessage} is
100
+ * {@link ProtocolMessageMaxSize}. This ensures every {@link ProtocolMessage} is
102
101
  * less than or equal to this limit, enabling stateless transports, simplified
103
102
  * relay implementation, and predictable memory usage. When all messages don't
104
103
  * fit within the limit, the protocol automatically continues synchronization in
105
104
  * subsequent rounds using range-based reconciliation.
106
105
  *
107
- * Individual database mutations are limited to `maxMutationSize` (640KB), which
108
- * is smaller than the protocol message limit to ensure efficient sync with
109
- * {@link maxProtocolMessageRangesSize}.
106
+ * Database mutations are limited to 640KB, which is smaller than the protocol
107
+ * message limit to ensure efficient sync with
108
+ * {@link defaultProtocolMessageRangesMaxSize}.
110
109
  *
111
110
  * ### Why Binary?
112
111
  *
@@ -149,6 +148,15 @@
149
148
  * Version negotiation is per-owner, allowing Evolu Protocol to evolve safely
150
149
  * over time and provide clear feedback about version mismatches.
151
150
  *
151
+ * ### Credible exit
152
+ *
153
+ * The protocol specification is intentionally non-configurable to ensure
154
+ * universal compatibility. This design allows applications (users) to switch
155
+ * between any compliant relay without negotiation or compatibility checks
156
+ * beyond version matching. Relays are generic infrastructure that any
157
+ * application can use interchangeably making exit from any single provider
158
+ * technically feasible and economically viable.
159
+ *
152
160
  * @module
153
161
  */
154
162
 
@@ -179,12 +187,14 @@ import { SqliteValue } from "../Sqlite.js";
179
187
  import {
180
188
  Base64Url,
181
189
  base64UrlToUint8Array,
190
+ between,
182
191
  DateIso,
183
192
  Id,
184
193
  IdBytes,
185
194
  idBytesToId,
186
195
  idBytesTypeValueLength,
187
196
  idToIdBytes,
197
+ Int,
188
198
  Json,
189
199
  jsonToJsonValue,
190
200
  NonNegativeInt,
@@ -231,18 +241,71 @@ import {
231
241
  } from "./Timestamp.js";
232
242
 
233
243
  /**
234
- * MessagePack serializer for standard compatibility and compact encoding.
244
+ * Evolu uses MessagePack for numbers and JSONs.
235
245
  *
236
246
  * - `variableMapSize: true` - More compact maps, ~5-10% slower encoding
237
247
  * - `useRecords: false` - Standard MessagePack without extensions
238
248
  */
239
249
  const packr = new Packr({ variableMapSize: true, useRecords: false });
240
250
 
241
- /** Maximum size of the entire protocol message in bytes. */
242
- export const maxProtocolMessageSize = 1_000_000 as PositiveInt;
251
+ const minProtocolMessageMaxSize = 1_000_000;
252
+ const maxProtocolMessageMaxSize = 100_000_000;
253
+
254
+ /**
255
+ * Protocol message maximum size.
256
+ *
257
+ * Defines the upper limit for how large a single protocol message can be.
258
+ * Implementations must enforce a maximum size between 1MB and 100MB to ensure
259
+ * compatibility across all Evolu implementations (the maximum size of mutation
260
+ * change is hardcoded and enforced hence the maximum size can't be smaller).
261
+ *
262
+ * Larger maximum sizes can be configured by relays to reduce roundtrips. For
263
+ * example, a dedicated relay with ample resources could configure a 100MB
264
+ * maximum to minimize roundtrips for large syncs.
265
+ *
266
+ * Only relays can safely configure larger sizes, as clients will handle them.
267
+ * Increasing this value on the client side would break compatibility with
268
+ * relays that enforce smaller limits.
269
+ */
270
+ export const ProtocolMessageMaxSize = between(
271
+ minProtocolMessageMaxSize,
272
+ maxProtocolMessageMaxSize,
273
+ )(Int);
243
274
 
244
- /** Maximum size of the ranges in bytes. */
245
- export const maxProtocolMessageRangesSize = 30_000 as PositiveInt;
275
+ export type ProtocolMessageMaxSize = typeof ProtocolMessageMaxSize.Type;
276
+
277
+ /**
278
+ * Default {@link ProtocolMessageMaxSize} (1MB).
279
+ *
280
+ * The standard size used across Evolu implementations. Relays with more
281
+ * resources can configure larger sizes to reduce roundtrips.
282
+ */
283
+ export const defaultProtocolMessageMaxSize =
284
+ minProtocolMessageMaxSize as ProtocolMessageMaxSize;
285
+
286
+ /**
287
+ * Protocol message ranges maximum size.
288
+ *
289
+ * Defines the upper limit for how large the ranges section of a protocol
290
+ * message can be. Implementations must enforce a maximum size between 3KB and
291
+ * 100KB to ensure compatibility.
292
+ *
293
+ * The upper bound is set to ensure ranges fit within the default 1MB
294
+ * {@link defaultProtocolMessageMaxSize}, maintaining compatibility between all
295
+ * clients and relays.
296
+ */
297
+ export const ProtocolMessageRangesMaxSize = between(3_000, 100_000)(Int);
298
+ export type ProtocolMessageRangesMaxSize =
299
+ typeof ProtocolMessageRangesMaxSize.Type;
300
+
301
+ /**
302
+ * Default {@link ProtocolMessageRangesMaxSize} (30KB).
303
+ *
304
+ * The standard size used across Evolu implementations. Relays with more
305
+ * resources can configure larger sizes to reduce roundtrips.
306
+ */
307
+ export const defaultProtocolMessageRangesMaxSize =
308
+ 30_000 as ProtocolMessageRangesMaxSize;
246
309
 
247
310
  /** Evolu Protocol Message. */
248
311
  export type ProtocolMessage = Uint8Array & Brand<"ProtocolMessage">;
@@ -281,6 +344,8 @@ export const ProtocolErrorCode = {
281
344
  WriteError: 2,
282
345
  /** A code for {@link ProtocolSyncError}. */
283
346
  SyncError: 3,
347
+ /** A code for {@link ProtocolQuotaExceededError}. */
348
+ QuotaExceededError: 4,
284
349
  } as const;
285
350
 
286
351
  type ProtocolErrorCode =
@@ -292,6 +357,7 @@ export type ProtocolError =
292
357
  | ProtocolWriteKeyError
293
358
  | ProtocolWriteError
294
359
  | ProtocolSyncError
360
+ | ProtocolQuotaExceededError
295
361
  | ProtocolTimestampMismatchError;
296
362
 
297
363
  /** Base interface for all protocol errors. */
@@ -323,21 +389,32 @@ export interface ProtocolWriteKeyError extends ProtocolErrorBase {
323
389
  }
324
390
 
325
391
  /**
326
- * Error when a write fails due to storage limits or billing requirements.
327
- * Indicates the need to expand capacity or resolve payment issues.
392
+ * Error indicating a serious relay-side write failure. Clients should log this
393
+ * error and show a generic sync error to the user.
328
394
  */
329
395
  export interface ProtocolWriteError extends ProtocolErrorBase {
330
396
  readonly type: "ProtocolWriteError";
331
397
  }
332
398
 
333
399
  /**
334
- * Error indicating a synchronization failure during the protocol exchange. Used
335
- * for unexpected or generic sync errors not covered by other error types.
400
+ * Error indicating a serious relay-side synchronization failure. Clients should
401
+ * log this error and show a generic sync error to the user.
336
402
  */
337
403
  export interface ProtocolSyncError extends ProtocolErrorBase {
338
404
  readonly type: "ProtocolSyncError";
339
405
  }
340
406
 
407
+ /**
408
+ * Error when storage or billing quota is exceeded. Clients should prompt the
409
+ * user to upgrade their plan or expand capacity.
410
+ *
411
+ * TODO: Add callback to relay config to check quota and return this error when
412
+ * limits are reached.
413
+ */
414
+ export interface ProtocolQuotaExceededError extends ProtocolErrorBase {
415
+ readonly type: "ProtocolQuotaExceededError";
416
+ }
417
+
341
418
  /**
342
419
  * Error when embedded timestamp doesn't match expected timestamp in
343
420
  * EncryptedDbChange. Indicates potential tampering or corruption of CRDT
@@ -352,8 +429,8 @@ export interface ProtocolTimestampMismatchError {
352
429
  /**
353
430
  * Creates a {@link ProtocolMessage} from CRDT messages.
354
431
  *
355
- * If the message size would exceed {@link maxProtocolMessageSize}, the protocol
356
- * ensures all messages will be sent in the next round(s) even over
432
+ * If the message size would exceed {@link defaultProtocolMessageMaxSize}, the
433
+ * protocol ensures all messages will be sent in the next round(s) even over
357
434
  * unidirectional and stateless transports.
358
435
  */
359
436
  export const createProtocolMessageFromCrdtMessages =
@@ -361,11 +438,11 @@ export const createProtocolMessageFromCrdtMessages =
361
438
  (
362
439
  owner: Owner,
363
440
  messages: NonEmptyReadonlyArray<CrdtMessage>,
364
- maxSize?: PositiveInt,
441
+ maxSize?: ProtocolMessageMaxSize,
365
442
  ): ProtocolMessage => {
366
443
  const buffer = createProtocolMessageBuffer(owner.id, {
367
444
  messageType: MessageType.Request,
368
- totalMaxSize: maxSize ?? maxProtocolMessageSize,
445
+ totalMaxSize: maxSize ?? defaultProtocolMessageMaxSize,
369
446
  writeKey: owner.writeKey,
370
447
  });
371
448
 
@@ -394,8 +471,8 @@ export const createProtocolMessageFromCrdtMessages =
394
471
  *
395
472
  * The ideal approach would be to send three ranges (skip, fingerprint,
396
473
  * skip) where the fingerprint of unsent messages would act as narrow sync
397
- * probe. I think we can send {@link zeroFingerprint} which can be
398
- * interpreted as an indication that the other side should reply with
474
+ * probe. I think we can send `zeroFingerprint` which can be interpreted
475
+ * as an indication that the other side should reply with
399
476
  * {@link TimestampsRange}, so no need to restart syncing.
400
477
  *
401
478
  * For now, using a random fingerprint avoids extra complexity and is good
@@ -479,8 +556,8 @@ export interface ProtocolMessageBuffer {
479
556
  export const createProtocolMessageBuffer = (
480
557
  ownerId: OwnerId,
481
558
  options: {
482
- readonly totalMaxSize?: PositiveInt | undefined;
483
- readonly rangesMaxSize?: PositiveInt | undefined;
559
+ readonly totalMaxSize?: ProtocolMessageMaxSize | undefined;
560
+ readonly rangesMaxSize?: ProtocolMessageRangesMaxSize | undefined;
484
561
  readonly version?: NonNegativeInt;
485
562
  } & (
486
563
  | {
@@ -498,8 +575,8 @@ export const createProtocolMessageBuffer = (
498
575
  ),
499
576
  ): ProtocolMessageBuffer => {
500
577
  const {
501
- totalMaxSize = maxProtocolMessageSize,
502
- rangesMaxSize = maxProtocolMessageRangesSize,
578
+ totalMaxSize = defaultProtocolMessageMaxSize,
579
+ rangesMaxSize = defaultProtocolMessageRangesMaxSize,
503
580
  version = protocolVersion,
504
581
  } = options;
505
582
 
@@ -788,8 +865,7 @@ export interface ApplyProtocolMessageAsClientOptions {
788
865
  /** For testing purposes only; should not be used in production. */
789
866
  version?: NonNegativeInt;
790
867
 
791
- totalMaxSize?: PositiveInt;
792
- rangesMaxSize?: PositiveInt;
868
+ rangesMaxSize?: ProtocolMessageRangesMaxSize;
793
869
  }
794
870
 
795
871
  /**
@@ -814,6 +890,7 @@ export const applyProtocolMessageAsClient =
814
890
  | ProtocolUnsupportedVersionError
815
891
  | ProtocolWriteError
816
892
  | ProtocolWriteKeyError
893
+ | ProtocolQuotaExceededError
817
894
  >
818
895
  > => {
819
896
  // try-catch instead of Result for performance and stacktraces
@@ -857,6 +934,11 @@ export const applyProtocolMessageAsClient =
857
934
  type: "ProtocolSyncError",
858
935
  ownerId,
859
936
  });
937
+ case ProtocolErrorCode.QuotaExceededError:
938
+ return err<ProtocolQuotaExceededError>({
939
+ type: "ProtocolQuotaExceededError",
940
+ ownerId,
941
+ });
860
942
  default:
861
943
  throw new ProtocolDecodeError(
862
944
  `Invalid ProtocolErrorCode: ${errorCode}`,
@@ -898,7 +980,6 @@ export const applyProtocolMessageAsClient =
898
980
  const output = createProtocolMessageBuffer(ownerId, {
899
981
  messageType: MessageType.Request,
900
982
  writeKey,
901
- totalMaxSize: options.totalMaxSize,
902
983
  rangesMaxSize: options.rangesMaxSize,
903
984
  });
904
985
 
@@ -929,8 +1010,8 @@ export interface ApplyProtocolMessageAsRelayOptions {
929
1010
  /** To broadcast a protocol message to all subscribers. */
930
1011
  broadcast?: (ownerId: OwnerId, message: ProtocolMessage) => void;
931
1012
 
932
- totalMaxSize?: PositiveInt;
933
- rangesMaxSize?: PositiveInt;
1013
+ totalMaxSize?: ProtocolMessageMaxSize;
1014
+ rangesMaxSize?: ProtocolMessageRangesMaxSize;
934
1015
  }
935
1016
 
936
1017
  /**
@@ -1,10 +1,12 @@
1
1
  import { isNonEmptyReadonlyArray } from "../Array.js";
2
- import { ConsoleConfig } from "../Console.js";
2
+ import { ConsoleConfig, ConsoleDep } from "../Console.js";
3
3
  import { TimingSafeEqualDep } from "../Crypto.js";
4
+ import { LazyValue } from "../Function.js";
4
5
  import { err, ok, Result } from "../Result.js";
5
6
  import { sql, SqliteError } from "../Sqlite.js";
6
7
  import { SimpleName } from "../Type.js";
7
- import { OwnerId, OwnerWriteKey } from "./Owner.js";
8
+ import { OwnerId, OwnerWriteKey, TransportConfig } from "./Owner.js";
9
+ import { ProtocolInvalidDataError } from "./Protocol.js";
8
10
  import {
9
11
  createSqliteStorageBase,
10
12
  CreateSqliteStorageBaseOptions,
@@ -17,13 +19,55 @@ import { timestampToTimestampBytes } from "./Timestamp.js";
17
19
  export interface Relay extends Disposable {}
18
20
 
19
21
  export interface RelayConfig extends ConsoleConfig {
22
+ /**
23
+ * The relay name.
24
+ *
25
+ * Implementations can use this for identification purposes (e.g., database
26
+ * file name, logging).
27
+ */
20
28
  readonly name?: SimpleName;
21
- }
22
29
 
23
- export type RelaySqliteStorageDeps = SqliteStorageDeps & TimingSafeEqualDep;
30
+ /**
31
+ * Optional callback to authenticate an {@link OwnerId} with the relay.
32
+ *
33
+ * If this callback is not provided, all owners are allowed.
34
+ *
35
+ * If provided, the callback receives the OwnerId and should return a promise
36
+ * that resolves to `true` to allow access, or `false` to deny.
37
+ *
38
+ * The callback returns a boolean rather than an error type because error
39
+ * handling and logging are the responsibility of the callback implementation,
40
+ * not the relay. This prevents leaking authentication implementation details
41
+ * into the generic relay interface.
42
+ *
43
+ * OwnerId is used for authentication rather than short-lived tokens because
44
+ * this only controls relay access, not write permissions. Since all data is
45
+ * encrypted on the relay, OwnerId exposure is safe.
46
+ *
47
+ * Owners specify which relays to connect to via {@link TransportConfig}. In
48
+ * WebSocket-based implementations, this check occurs before accepting the
49
+ * connection, with the OwnerId typically extracted from the URL path (e.g.,
50
+ * `ws://localhost:4000/<ownerId>`).
51
+ *
52
+ * ### Example
53
+ *
54
+ * ```ts
55
+ * const relay = await createNodeJsRelay(deps)({
56
+ * authenticateOwner: async (ownerId) => {
57
+ * const isRegistered = await db.checkOwner(ownerId);
58
+ * if (!isRegistered) {
59
+ * logger.warn("Unauthorized access attempt", { ownerId });
60
+ * }
61
+ * return isRegistered;
62
+ * },
63
+ * });
64
+ * ```
65
+ */
66
+ readonly authenticateOwner?: (ownerId: OwnerId) => Promise<boolean>;
67
+ }
24
68
 
25
- export const createRelayStorage =
26
- (deps: RelaySqliteStorageDeps) =>
69
+ export const createRelaySqliteStorage =
70
+ (deps: SqliteStorageDeps & TimingSafeEqualDep) =>
27
71
  (options: CreateSqliteStorageBaseOptions): Result<Storage, SqliteError> => {
28
72
  const sqliteStorageBase = createSqliteStorageBase(deps)(options);
29
73
  if (!sqliteStorageBase.ok) return sqliteStorageBase;
@@ -186,3 +230,133 @@ export const createRelayStorage =
186
230
  },
187
231
  });
188
232
  };
233
+
234
+ export interface RelayLogger {
235
+ readonly started: (enableLogging: boolean, port: number) => void;
236
+ readonly storageError: (error: unknown) => void;
237
+ readonly upgradeSocketError: (error: Error) => void;
238
+ readonly invalidOrMissingOwnerIdInUrl: (url: string | undefined) => void;
239
+ readonly unauthorizedOwner: (ownerId: OwnerId) => void;
240
+ readonly authenticateOwnerError: (error: unknown) => void;
241
+ readonly connectionEstablished: (totalConnectionCount: number) => void;
242
+ readonly connectionWebSocketError: (error: Error) => void;
243
+ readonly relayOptionSubscribe: (
244
+ ownerId: OwnerId,
245
+ getSubscriberCount: LazyValue<number>,
246
+ ) => void;
247
+ readonly relayOptionUnsubscribe: (
248
+ ownerId: OwnerId,
249
+ getSubscriberCount: LazyValue<number>,
250
+ ) => void;
251
+ readonly relayOptionBroadcast: (
252
+ ownerId: OwnerId,
253
+ broadcastCount: number,
254
+ subscriberCount: number,
255
+ ) => void;
256
+ readonly messageLength: (messageLength: number) => void;
257
+ readonly applyProtocolMessageAsRelayError: (
258
+ error: ProtocolInvalidDataError,
259
+ ) => void;
260
+ readonly responseLength: (responseLength: number) => void;
261
+ readonly applyProtocolMessageAsRelayUnknownError: (error: unknown) => void;
262
+ readonly connectionClosed: (totalConnectionCount: number) => void;
263
+ readonly shuttingDown: () => void;
264
+ readonly webSocketServerDisposed: () => void;
265
+ readonly httpServerDisposed: () => void;
266
+ }
267
+
268
+ export const createRelayLogger = (deps: ConsoleDep): RelayLogger => ({
269
+ started: (enableLogging, port) => {
270
+ deps.console.enabled = true;
271
+ deps.console.log(`Evolu Relay started on port ${port}`);
272
+ deps.console.enabled = enableLogging;
273
+ },
274
+
275
+ storageError: (error) => {
276
+ deps.console.error("[relay]", "storage", error);
277
+ },
278
+
279
+ upgradeSocketError: (error) => {
280
+ deps.console.warn("[relay]", "socket error", { error });
281
+ },
282
+
283
+ invalidOrMissingOwnerIdInUrl: (url) => {
284
+ deps.console.warn("[relay]", "invalid or missing ownerId in URL", { url });
285
+ },
286
+
287
+ unauthorizedOwner: (ownerId) => {
288
+ deps.console.warn("[relay]", "unauthorized owner", { ownerId });
289
+ },
290
+
291
+ authenticateOwnerError: (error) => {
292
+ deps.console.error("[relay]", "authenticateOwner error", error);
293
+ },
294
+
295
+ connectionEstablished: (totalConnectionCount) => {
296
+ deps.console.log("[relay]", "connection", { totalConnectionCount });
297
+ },
298
+
299
+ connectionWebSocketError: (error) => {
300
+ deps.console.error("[relay]", "error", { error });
301
+ },
302
+
303
+ relayOptionSubscribe: (ownerId, getSubscriberCount) => {
304
+ if (deps.console.enabled)
305
+ deps.console.log("[relay]", "subscribe", {
306
+ ownerId,
307
+ subscriberCount: getSubscriberCount(),
308
+ });
309
+ },
310
+
311
+ relayOptionUnsubscribe: (ownerId, getSubscriberCount) => {
312
+ if (deps.console.enabled)
313
+ deps.console.log("[relay]", "unsubscribe", {
314
+ ownerId,
315
+ subscriberCount: getSubscriberCount(),
316
+ });
317
+ },
318
+
319
+ relayOptionBroadcast: (ownerId, broadcastCount, totalSubscribers) => {
320
+ deps.console.log("[relay]", "broadcast", {
321
+ ownerId,
322
+ broadcastCount,
323
+ totalSubscribers,
324
+ });
325
+ },
326
+
327
+ messageLength: (messageLength) => {
328
+ deps.console.log("[relay]", "on message", { messageLength });
329
+ },
330
+
331
+ applyProtocolMessageAsRelayError: (error) => {
332
+ deps.console.error("[relay]", "applyProtocolMessageAsRelay", error);
333
+ },
334
+
335
+ responseLength: (responseLength) => {
336
+ deps.console.log("[relay]", "responseLength", { responseLength });
337
+ },
338
+
339
+ applyProtocolMessageAsRelayUnknownError: (error) => {
340
+ deps.console.error(
341
+ "[relay]",
342
+ "applyProtocolMessageAsRelayUnknownError",
343
+ error,
344
+ );
345
+ },
346
+
347
+ connectionClosed: (totalConnectionCount) => {
348
+ deps.console.log("[relay]", "close", { totalConnectionCount });
349
+ },
350
+
351
+ shuttingDown: () => {
352
+ deps.console.log("Shutting down Evolu Relay...");
353
+ },
354
+
355
+ webSocketServerDisposed: () => {
356
+ deps.console.log("Evolu Relay WebSocketServer disposed");
357
+ },
358
+
359
+ httpServerDisposed: () => {
360
+ deps.console.log("Evolu Relay HTTP server disposed");
361
+ },
362
+ });
package/src/Evolu/Sync.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js";
2
2
  import { assert } from "../Assert.js";
3
3
  import { Brand } from "../Brand.js";
4
- import { concatBytes } from "../Buffer.js";
5
4
  import { ConsoleDep } from "../Console.js";
6
5
  import {
7
6
  RandomBytesDep,
@@ -60,7 +59,6 @@ import {
60
59
  receiveTimestamp,
61
60
  sendTimestamp,
62
61
  Timestamp,
63
- TimestampBytes,
64
62
  timestampBytesToTimestamp,
65
63
  TimestampConfigDep,
66
64
  TimestampCounterOverflowError,
@@ -634,7 +632,7 @@ export const applyLocalOnlyChange =
634
632
  return ok();
635
633
  };
636
634
 
637
- const applyMessages =
635
+ export const applyMessages =
638
636
  (deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
639
637
  (
640
638
  ownerId: OwnerId,
@@ -665,30 +663,26 @@ const applyMessageToAppTable =
665
663
  for (const [column, value] of objectToEntries(message.change.values)) {
666
664
  const result = deps.sqlite.exec(sql.prepared`
667
665
  with
668
- lastTimestamp as (
669
- select "timestamp"
666
+ existingTimestamp as (
667
+ select 1
670
668
  from evolu_history
671
669
  where
672
670
  "ownerId" = ${ownerId}
673
671
  and "table" = ${message.change.table}
674
- and "id" = ${message.change.id}
672
+ and "id" = ${idToIdBytes(message.change.id)}
675
673
  and "column" = ${column}
676
- order by "timestamp" desc
674
+ and "timestamp" >= ${timestamp}
677
675
  limit 1
678
676
  )
679
677
  insert into ${sql.identifier(message.change.table)}
680
678
  ("id", ${sql.identifier(column)}, updatedAt)
681
679
  select ${message.change.id}, ${value}, ${updatedAt}
682
- where
683
- (select "timestamp" from lastTimestamp) is null
684
- or (select "timestamp" from lastTimestamp) < ${timestamp}
680
+ where not exists (select 1 from existingTimestamp)
685
681
  on conflict ("id") do update
686
682
  set
687
683
  ${sql.identifier(column)} = ${value},
688
684
  updatedAt = ${updatedAt}
689
- where
690
- (select "timestamp" from lastTimestamp) is null
691
- or (select "timestamp" from lastTimestamp) < ${timestamp};
685
+ where not exists (select 1 from existingTimestamp);
692
686
  `);
693
687
 
694
688
  if (!result.ok) return result;
@@ -780,47 +774,3 @@ export interface PaymentRequiredError {
780
774
  }
781
775
 
782
776
  export const initialSyncState: SyncStateInitial = { type: "SyncStateInitial" };
783
-
784
- /**
785
- * Efficiently checks which binary timestamps already exist in the database
786
- * using a single CTE query instead of N individual queries. Crucial for WASM
787
- * SQLite performance where JS↔WASM boundary crossings are expensive.
788
- *
789
- * Used for fast idempotency detection in writeMessages before onMessage
790
- * validation. While applyMessages ensures internal idempotency, this pre-check
791
- * is faster and required for main thread message validation.
792
- */
793
- export const getExistingTimestamps =
794
- (deps: SqliteDep) =>
795
- (
796
- ownerIdBytes: OwnerIdBytes,
797
- timestampsBytes: NonEmptyReadonlyArray<TimestampBytes>,
798
- ): Result<ReadonlyArray<TimestampBytes>, SqliteError> => {
799
- const concatenatedTimestamps = concatBytes(...timestampsBytes);
800
-
801
- const result = deps.sqlite.exec<{
802
- timestampBytes: TimestampBytes;
803
- }>(sql`
804
- with recursive
805
- split_timestamps(timestampBytes, pos) as (
806
- select
807
- substr(${concatenatedTimestamps}, 1, 16),
808
- 17 as pos
809
- union all
810
- select
811
- substr(${concatenatedTimestamps}, pos, 16),
812
- pos + 16
813
- from split_timestamps
814
- where pos <= length(${concatenatedTimestamps})
815
- )
816
- select s.timestampBytes
817
- from
818
- split_timestamps s
819
- join evolu_timestamp t
820
- on t.ownerId = ${ownerIdBytes} and s.timestampBytes = t.t;
821
- `);
822
-
823
- if (!result.ok) return result;
824
-
825
- return ok(result.value.rows.map((row) => row.timestampBytes));
826
- };