@evolu/common 6.0.1-preview.28 → 6.0.1-preview.29

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 (59) hide show
  1. package/dist/src/Array.d.ts +58 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +53 -5
  4. package/dist/src/Evolu/Evolu.d.ts +3 -3
  5. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  6. package/dist/src/Evolu/Evolu.js +3 -3
  7. package/dist/src/Evolu/Owner.d.ts +48 -19
  8. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  9. package/dist/src/Evolu/Owner.js +11 -2
  10. package/dist/src/Evolu/Protocol.d.ts +31 -31
  11. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  12. package/dist/src/Evolu/Protocol.js +51 -28
  13. package/dist/src/Evolu/Relay.d.ts +40 -25
  14. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  15. package/dist/src/Evolu/Relay.js +106 -49
  16. package/dist/src/Evolu/Storage.d.ts +59 -12
  17. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  18. package/dist/src/Evolu/Storage.js +77 -50
  19. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  20. package/dist/src/Evolu/Sync.js +14 -5
  21. package/dist/src/Evolu/Timestamp.d.ts +25 -0
  22. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  23. package/dist/src/Evolu/Timestamp.js +25 -0
  24. package/dist/src/Instances.d.ts +34 -0
  25. package/dist/src/Instances.d.ts.map +1 -0
  26. package/dist/src/{Multiton.js → Instances.js} +20 -9
  27. package/dist/src/Sqlite.d.ts +6 -0
  28. package/dist/src/Sqlite.d.ts.map +1 -1
  29. package/dist/src/Sqlite.js +6 -0
  30. package/dist/src/Task.d.ts +75 -0
  31. package/dist/src/Task.d.ts.map +1 -1
  32. package/dist/src/Task.js +29 -6
  33. package/dist/src/Time.d.ts +7 -1
  34. package/dist/src/Time.d.ts.map +1 -1
  35. package/dist/src/Time.js +13 -2
  36. package/dist/src/Type.d.ts +56 -9
  37. package/dist/src/Type.d.ts.map +1 -1
  38. package/dist/src/Type.js +40 -8
  39. package/dist/src/index.d.ts +1 -1
  40. package/dist/src/index.d.ts.map +1 -1
  41. package/dist/src/index.js +1 -1
  42. package/package.json +1 -1
  43. package/src/Array.ts +76 -11
  44. package/src/Evolu/Evolu.ts +4 -5
  45. package/src/Evolu/Owner.ts +75 -26
  46. package/src/Evolu/Protocol.ts +90 -61
  47. package/src/Evolu/Relay.ts +182 -77
  48. package/src/Evolu/Storage.ts +157 -67
  49. package/src/Evolu/Sync.ts +18 -6
  50. package/src/Evolu/Timestamp.ts +25 -0
  51. package/src/Instances.ts +90 -0
  52. package/src/Sqlite.ts +6 -0
  53. package/src/Task.ts +88 -7
  54. package/src/Time.ts +13 -2
  55. package/src/Type.ts +56 -9
  56. package/src/index.ts +1 -1
  57. package/dist/src/Multiton.d.ts +0 -50
  58. package/dist/src/Multiton.d.ts.map +0 -1
  59. package/src/Multiton.ts +0 -98
@@ -8,6 +8,7 @@ import {
8
8
  Entropy32,
9
9
  RandomBytesDep,
10
10
  } from "../Crypto.js";
11
+ import { getOrNull } from "../Result.js";
11
12
  import {
12
13
  brand,
13
14
  Id,
@@ -16,12 +17,8 @@ import {
16
17
  idToIdBytes,
17
18
  Mnemonic,
18
19
  NonNegativeInt,
19
- PositiveInt,
20
20
  } from "../Type.js";
21
- import { getOrNull } from "../Result.js";
22
- import type { Timestamp } from "./Timestamp.js";
23
- import { TimestampBytes } from "./Timestamp.js";
24
- import type { Storage } from "./Storage.js";
21
+ import type { Storage, EncryptedDbChange } from "./Storage.js";
25
22
 
26
23
  /**
27
24
  * The Owner represents ownership of data in Evolu. Every database change is
@@ -159,6 +156,17 @@ export const createOwner = (secret: OwnerSecret): Owner => ({
159
156
  * preserved because it coordinates deletion information across devices. Other
160
157
  * devices need to sync the information that an owner was deleted so they can
161
158
  * delete their local data as well.
159
+ *
160
+ * ### Privacy Considerations
161
+ *
162
+ * AppOwner must never be shared with anyone, except for its {@link OwnerId},
163
+ * which can be used for authorization with
164
+ * {@link createOwnerWebSocketTransport}. It's safe because OwnerId is
165
+ * pseudonymous (it can't be assigned to a specific person).
166
+ *
167
+ * For data sharing scenarios, use {@link SharedOwner} and
168
+ * {@link SharedReadonlyOwner} instead, which are designed specifically for
169
+ * collaborative access.
162
170
  */
163
171
  export interface AppOwner extends Owner {
164
172
  readonly type: "AppOwner";
@@ -294,7 +302,17 @@ export type OwnerTransport = OwnerWebSocketTransport;
294
302
  /**
295
303
  * WebSocket transport configuration.
296
304
  *
297
- * ### Authentication and Error Handling
305
+ * ### Authentication via URL
306
+ *
307
+ * The {@link OwnerId} is passed as a URL query parameter. While this approach is
308
+ * generally discouraged for authentication tokens (they get logged), it's safe
309
+ * here because OwnerId is pseudonymous and used only for access verification -
310
+ * it provides no ability to read encrypted data or write changes.
311
+ *
312
+ * See: [HTTP headers in Websockets client
313
+ * API](https://stackoverflow.com/questions/4361173/http-headers-in-websockets-client-api/74564827#74564827)
314
+ *
315
+ * ### Error Handling
298
316
  *
299
317
  * When a relay rejects a connection (invalid OwnerId, unauthorized owner, or
300
318
  * server error), the browser WebSocket API does not expose the specific HTTP
@@ -317,14 +335,23 @@ export interface OwnerWebSocketTransport {
317
335
  * Creates an {@link OwnerWebSocketTransport} for the given relay URL and
318
336
  * {@link OwnerId}.
319
337
  *
338
+ * The URL must be a WebSocket base URL without query parameters or fragments
339
+ * (e.g., `wss://relay.evolu.dev`, not `wss://relay.evolu.dev?foo=bar`). The
340
+ * function appends the `ownerId` as a query parameter.
341
+ *
320
342
  * ### Example
321
343
  *
322
344
  * ```ts
345
+ * // Create transport "wss://relay.evolu.dev?ownerId=..."
323
346
  * const transport = createOwnerWebSocketTransport({
324
347
  * url: "wss://relay.evolu.dev",
325
348
  * ownerId: owner.id,
326
349
  * });
327
- * // Result: { type: "WebSocket", url: "wss://relay.evolu.dev?ownerId=..." }
350
+ *
351
+ * // Use with createEvolu
352
+ * const evolu = createEvolu(deps)(Schema, {
353
+ * transports: [transport],
354
+ * });
328
355
  * ```
329
356
  */
330
357
  export const createOwnerWebSocketTransport = (config: {
@@ -355,6 +382,11 @@ export const parseOwnerIdFromOwnerWebSocketTransportUrl = (
355
382
  url: string,
356
383
  ): OwnerId | null => getOrNull(OwnerId.fromUnknown(url.split("=")[1]));
357
384
 
385
+ /** Base interface for all owner errors. */
386
+ export interface BaseOwnerError {
387
+ readonly ownerId: OwnerId;
388
+ }
389
+
358
390
  /**
359
391
  * Usage data for an {@link OwnerId}.
360
392
  *
@@ -368,27 +400,44 @@ export interface OwnerUsage {
368
400
  /** The {@link Owner} this usage data belongs to. */
369
401
  readonly ownerId: OwnerIdBytes;
370
402
 
371
- /** Total bytes stored in the database. */
372
- readonly storedBytes: PositiveInt;
373
-
374
- /** Total bytes received. */
375
- readonly receivedBytes: number;
376
-
377
- /** Total bytes sent. */
378
- readonly sentBytes: number;
379
-
380
403
  /**
381
- * The minimum {@link Timestamp}.
404
+ * Total logical data bytes stored.
382
405
  *
383
- * Helps {@link Storage} choose faster algorithms.
384
- */
385
- readonly firstTimestamp: TimestampBytes | null;
386
-
387
- /**
388
- * The maximum {@link Timestamp}.
406
+ * Measures the size of {@link EncryptedDbChange}s only, excluding
407
+ * {@link Storage} implementation overhead (with SqliteStorage: indexes,
408
+ * skiplist columns, etc.). This provides:
389
409
  *
390
- * Helps {@link Storage} choose faster algorithms. Free relays can use it to
391
- * identify inactive accounts for cleanup or archival.
410
+ * - **Predictable measurement** - same data = same byte count across all
411
+ * instances
412
+ * - **Quota enforcement** - consistent billing/limits independent of storage
413
+ * implementation
414
+ * - **Overhead tracking** - actual Storage size can be compared against this to
415
+ * monitor efficiency
392
416
  */
393
- readonly lastTimestamp: TimestampBytes | null;
417
+ readonly storedBytes: NonNegativeInt;
418
+
419
+ // TODO: Decide how to use receivedBytes and sentBytes.
420
+ // /** Total bytes received. */
421
+ // readonly receivedBytes: NonNegativeInt;
422
+
423
+ // TODO: Decide how to use sentBytes.
424
+ // /** Total bytes sent. */
425
+ // readonly sentBytes: NonNegativeInt;
426
+
427
+ // TODO: Decide how to use firstTimestamp.
428
+ // /**
429
+ // * The minimum {@link Timestamp}.
430
+ // *
431
+ // * Helps {@link Storage} choose faster algorithms.
432
+ // */
433
+ // readonly firstTimestamp: TimestampBytes | null;
434
+
435
+ // TODO: Decide how to use lastTimestamp.
436
+ // /**
437
+ // * The maximum {@link Timestamp}.
438
+ // *
439
+ // * Helps {@link Storage} choose faster algorithms. Free relays can use it to
440
+ // * identify inactive accounts for cleanup or archival.
441
+ // */
442
+ // readonly lastTimestamp: TimestampBytes | null;
394
443
  }
@@ -85,10 +85,10 @@
85
85
  *
86
86
  * - {@link ProtocolWriteKeyError}: The provided WriteKey is invalid or missing.
87
87
  * - {@link ProtocolWriteError}: A serious relay-side write failure occurred.
88
+ * - {@link ProtocolQuotaError}: Storage or billing quota exceeded.
88
89
  * - {@link ProtocolSyncError}: A serious relay-side synchronization failure
89
90
  * occurred.
90
- * - {@link ProtocolQuotaExceededError}: Storage or billing quota exceeded.
91
- * - {@link ProtocolUnsupportedVersionError}: Protocol version mismatch.
91
+ * - {@link ProtocolVersionError}: Protocol version mismatch.
92
92
  * - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
93
93
  *
94
94
  * All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
@@ -160,6 +160,19 @@
160
160
  * @module
161
161
  */
162
162
 
163
+ /**
164
+ * TODO:
165
+ *
166
+ * - The client-relay naming convention in functions like
167
+ * `applyProtocolMessageAsClient` and `applyProtocolMessageAsRelay` is not
168
+ * ideal. In the future, clients will be able to sync directly with each other
169
+ * (P2P), making the current naming misleading. Consider using
170
+ * initiator/non-initiator terminology instead, and consolidate into a single
171
+ * `applyProtocolMessage` function with conditional arguments to reduce code
172
+ * duplication.
173
+ * - ProtocolQuotaError should return storedBytes and actual quota.
174
+ */
175
+
163
176
  import { Packr } from "msgpackr";
164
177
  import { isNonEmptyReadonlyArray, NonEmptyReadonlyArray } from "../Array.js";
165
178
  import { assert } from "../Assert.js";
@@ -204,6 +217,7 @@ import {
204
217
  } from "../Type.js";
205
218
  import { Predicate } from "../Types.js";
206
219
  import {
220
+ BaseOwnerError,
207
221
  Owner,
208
222
  OwnerId,
209
223
  OwnerIdBytes,
@@ -342,36 +356,31 @@ export const ProtocolErrorCode = {
342
356
  WriteKeyError: 1,
343
357
  /** A code for {@link ProtocolWriteError}. */
344
358
  WriteError: 2,
359
+ /** A code for {@link ProtocolQuotaError}. */
360
+ QuotaError: 3,
345
361
  /** A code for {@link ProtocolSyncError}. */
346
- SyncError: 3,
347
- /** A code for {@link ProtocolQuotaExceededError}. */
348
- QuotaExceededError: 4,
362
+ SyncError: 4,
349
363
  } as const;
350
364
 
351
365
  type ProtocolErrorCode =
352
366
  (typeof ProtocolErrorCode)[keyof typeof ProtocolErrorCode];
353
367
 
354
368
  export type ProtocolError =
355
- | ProtocolUnsupportedVersionError
369
+ | ProtocolVersionError
356
370
  | ProtocolInvalidDataError
357
371
  | ProtocolWriteKeyError
358
372
  | ProtocolWriteError
359
373
  | ProtocolSyncError
360
- | ProtocolQuotaExceededError
374
+ | ProtocolQuotaError
361
375
  | ProtocolTimestampMismatchError;
362
376
 
363
- /** Base interface for all protocol errors. */
364
- export interface ProtocolErrorBase {
365
- readonly ownerId: OwnerId;
366
- }
367
-
368
377
  /**
369
378
  * Represents a version mismatch in the Evolu Protocol. Occurs when the
370
379
  * initiator and non-initiator are using incompatible protocol versions.
371
380
  */
372
- export interface ProtocolUnsupportedVersionError extends ProtocolErrorBase {
373
- readonly type: "ProtocolUnsupportedVersionError";
374
- readonly unsupportedVersion: NonNegativeInt;
381
+ export interface ProtocolVersionError extends BaseOwnerError {
382
+ readonly type: "ProtocolVersionError";
383
+ readonly version: NonNegativeInt;
375
384
  /** Indicates which side is obsolete and should update. */
376
385
  readonly isInitiator: boolean;
377
386
  }
@@ -384,7 +393,7 @@ export interface ProtocolInvalidDataError {
384
393
  }
385
394
 
386
395
  /** Error when a {@link OwnerWriteKey} is invalid, missing, or fails validation. */
387
- export interface ProtocolWriteKeyError extends ProtocolErrorBase {
396
+ export interface ProtocolWriteKeyError extends BaseOwnerError {
388
397
  readonly type: "ProtocolWriteKeyError";
389
398
  }
390
399
 
@@ -392,27 +401,31 @@ export interface ProtocolWriteKeyError extends ProtocolErrorBase {
392
401
  * Error indicating a serious relay-side write failure. Clients should log this
393
402
  * error and show a generic sync error to the user.
394
403
  */
395
- export interface ProtocolWriteError extends ProtocolErrorBase {
404
+ export interface ProtocolWriteError extends BaseOwnerError {
396
405
  readonly type: "ProtocolWriteError";
397
406
  }
398
407
 
399
408
  /**
400
- * Error indicating a serious relay-side synchronization failure. Clients should
401
- * log this error and show a generic sync error to the user.
409
+ * Error when storage or billing quota is exceeded.
410
+ *
411
+ * When relay rejects writes due to quota, the affected device stops syncing
412
+ * because RBSR requires both sides to converge—if the relay won't accept the
413
+ * client's data, they can never reach the same state. Only the device with
414
+ * excess local data is affected. Other devices that haven't exceeded quota can
415
+ * still sync normally.
416
+ *
417
+ * Clients should prompt the user to upgrade their plan.
402
418
  */
403
- export interface ProtocolSyncError extends ProtocolErrorBase {
404
- readonly type: "ProtocolSyncError";
419
+ export interface ProtocolQuotaError extends BaseOwnerError {
420
+ readonly type: "ProtocolQuotaError";
405
421
  }
406
422
 
407
423
  /**
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.
424
+ * Error indicating a serious relay-side synchronization failure. Clients should
425
+ * log this error and show a generic sync error to the user.
413
426
  */
414
- export interface ProtocolQuotaExceededError extends ProtocolErrorBase {
415
- readonly type: "ProtocolQuotaExceededError";
427
+ export interface ProtocolSyncError extends BaseOwnerError {
428
+ readonly type: "ProtocolSyncError";
416
429
  }
417
430
 
418
431
  /**
@@ -862,10 +875,10 @@ const createRunLengthEncoder = <T>(
862
875
  export interface ApplyProtocolMessageAsClientOptions {
863
876
  getWriteKey?: (ownerId: OwnerId) => OwnerWriteKey | null;
864
877
 
865
- /** For testing purposes only; should not be used in production. */
866
- version?: NonNegativeInt;
867
-
868
878
  rangesMaxSize?: ProtocolMessageRangesMaxSize;
879
+
880
+ /** For tests only. */
881
+ version?: NonNegativeInt;
869
882
  }
870
883
 
871
884
  /**
@@ -887,22 +900,23 @@ export const applyProtocolMessageAsClient =
887
900
  ApplyProtocolMessageAsClientResult,
888
901
  | ProtocolInvalidDataError
889
902
  | ProtocolSyncError
890
- | ProtocolUnsupportedVersionError
903
+ | ProtocolVersionError
891
904
  | ProtocolWriteError
892
905
  | ProtocolWriteKeyError
893
- | ProtocolQuotaExceededError
906
+ | ProtocolQuotaError
894
907
  >
895
908
  > => {
896
909
  // try-catch instead of Result for performance and stacktraces
910
+ // DEV: Measure it again, I think we should use Result with new Error.
897
911
  try {
898
912
  const input = createBuffer(inputMessage);
899
913
  const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
900
914
  const version = options.version ?? protocolVersion;
901
915
 
902
916
  if (requestedVersion !== version) {
903
- return err<ProtocolUnsupportedVersionError>({
904
- type: "ProtocolUnsupportedVersionError",
905
- unsupportedVersion: requestedVersion,
917
+ return err<ProtocolVersionError>({
918
+ type: "ProtocolVersionError",
919
+ version: requestedVersion,
906
920
  isInitiator: version < requestedVersion,
907
921
  ownerId,
908
922
  });
@@ -929,16 +943,16 @@ export const applyProtocolMessageAsClient =
929
943
  type: "ProtocolWriteError",
930
944
  ownerId,
931
945
  });
946
+ case ProtocolErrorCode.QuotaError:
947
+ return err<ProtocolQuotaError>({
948
+ type: "ProtocolQuotaError",
949
+ ownerId,
950
+ });
932
951
  case ProtocolErrorCode.SyncError:
933
952
  return err<ProtocolSyncError>({
934
953
  type: "ProtocolSyncError",
935
954
  ownerId,
936
955
  });
937
- case ProtocolErrorCode.QuotaExceededError:
938
- return err<ProtocolQuotaExceededError>({
939
- type: "ProtocolQuotaExceededError",
940
- ownerId,
941
- });
942
956
  default:
943
957
  throw new ProtocolDecodeError(
944
958
  `Invalid ProtocolErrorCode: ${errorCode}`,
@@ -950,11 +964,13 @@ export const applyProtocolMessageAsClient =
950
964
  const messages = decodeMessages(input);
951
965
  const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
952
966
 
953
- if (
954
- isNonEmptyReadonlyArray(messages) &&
955
- !(await deps.storage.writeMessages(ownerIdBytes, messages))
956
- ) {
957
- return ok({ type: "no-response" });
967
+ if (isNonEmptyReadonlyArray(messages)) {
968
+ const writeResult = await deps.storage.writeMessages(
969
+ ownerIdBytes,
970
+ messages,
971
+ );
972
+ // Errors are handled by the Storage. Here we just stop syncing.
973
+ if (!writeResult.ok) return ok({ type: "no-response" });
958
974
  }
959
975
 
960
976
  // Now: No writeKey, no sync.
@@ -1033,12 +1049,13 @@ export const applyProtocolMessageAsRelay =
1033
1049
  async (
1034
1050
  inputMessage: Uint8Array,
1035
1051
  options: ApplyProtocolMessageAsRelayOptions = {},
1036
- /** For testing purposes only; should not be used in production. */
1052
+ /** For tests only. */
1037
1053
  version = protocolVersion,
1038
1054
  ): Promise<
1039
1055
  Result<ApplyProtocolMessageAsRelayResult, ProtocolInvalidDataError>
1040
1056
  > => {
1041
1057
  // try-catch instead of Result for performance and stacktraces
1058
+ // DEV: Measure it again, I think we should use Result with new Error.
1042
1059
  try {
1043
1060
  const input = createBuffer(inputMessage);
1044
1061
  const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
@@ -1104,17 +1121,39 @@ export const applyProtocolMessageAsRelay =
1104
1121
  });
1105
1122
  }
1106
1123
 
1124
+ const writeResult = await deps.storage.writeMessages(
1125
+ ownerIdBytes,
1126
+ messages,
1127
+ );
1128
+
1129
+ if (!writeResult.ok) {
1130
+ const errorCode =
1131
+ writeResult.error.type === "StorageWriteError"
1132
+ ? ProtocolErrorCode.WriteError
1133
+ : ProtocolErrorCode.QuotaError;
1134
+ const message = createProtocolMessageBuffer(ownerId, {
1135
+ messageType: MessageType.Response,
1136
+ errorCode,
1137
+ }).unwrap();
1138
+ return ok({ type: "response", message });
1139
+ }
1140
+
1107
1141
  /**
1108
- * Broadcast messages to all subscribed devices. This ensures real-time
1142
+ * Broadcast messages to all subscribed owners for real-time
1109
1143
  * synchronization between clients.
1110
1144
  *
1145
+ * Messages are only broadcasted after successful write to ensure
1146
+ * devices that can still sync aren't affected by quota errors, and to
1147
+ * prevent using a half-working relay service (broadcasting without
1148
+ * persistence).
1149
+ *
1111
1150
  * When a relay's database is deleted or clients migrate to a new relay
1112
1151
  * (without data migration), clients will sync their data to the relay,
1113
1152
  * and the relay will broadcast those messages to other connected
1114
1153
  * clients. Those clients may receive messages they already have, but
1115
- * this is safe because `applyMessages` is idempotent. As the relay
1116
- * becomes more synchronized with clients over time, fewer duplicate
1117
- * messages will be broadcasted.
1154
+ * this is safe because Evolu sync is idempotent. As the relay becomes
1155
+ * more synchronized with clients over time, fewer duplicate messages
1156
+ * will be broadcasted.
1118
1157
  */
1119
1158
  if (options.broadcast) {
1120
1159
  const broadcastBuffer = createProtocolMessageBuffer(ownerId, {
@@ -1128,16 +1167,6 @@ export const applyProtocolMessageAsRelay =
1128
1167
  }
1129
1168
  options.broadcast(ownerId, broadcastBuffer.unwrap());
1130
1169
  }
1131
-
1132
- if (!(await deps.storage.writeMessages(ownerIdBytes, messages))) {
1133
- return ok({
1134
- type: "response",
1135
- message: createProtocolMessageBuffer(ownerId, {
1136
- messageType: MessageType.Response,
1137
- errorCode: ProtocolErrorCode.WriteError,
1138
- }).unwrap(),
1139
- });
1140
- }
1141
1170
  }
1142
1171
 
1143
1172
  const ranges = decodeRanges(input);