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

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 (140) hide show
  1. package/dist/src/Assert.d.ts.map +1 -1
  2. package/dist/src/Assert.js +1 -1
  3. package/dist/src/Buffer.d.ts +1 -1
  4. package/dist/src/Buffer.d.ts.map +1 -1
  5. package/dist/src/Buffer.js +1 -1
  6. package/dist/src/CallbackRegistry.d.ts +53 -0
  7. package/dist/src/CallbackRegistry.d.ts.map +1 -0
  8. package/dist/src/CallbackRegistry.js +25 -0
  9. package/dist/src/Console.d.ts +31 -6
  10. package/dist/src/Console.d.ts.map +1 -1
  11. package/dist/src/Console.js +72 -9
  12. package/dist/src/Crypto.d.ts +48 -37
  13. package/dist/src/Crypto.d.ts.map +1 -1
  14. package/dist/src/Crypto.js +27 -50
  15. package/dist/src/Evolu/Db.d.ts +138 -66
  16. package/dist/src/Evolu/Db.d.ts.map +1 -1
  17. package/dist/src/Evolu/Db.js +248 -645
  18. package/dist/src/Evolu/Diff.d.ts +3 -3
  19. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  20. package/dist/src/Evolu/Diff.js +7 -5
  21. package/dist/src/Evolu/Evolu.d.ts +79 -116
  22. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  23. package/dist/src/Evolu/Evolu.js +275 -132
  24. package/dist/src/Evolu/Internal.d.ts +0 -2
  25. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  26. package/dist/src/Evolu/Internal.js +0 -2
  27. package/dist/src/Evolu/LocalAuth.d.ts +144 -0
  28. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  29. package/dist/src/Evolu/LocalAuth.js +171 -0
  30. package/dist/src/Evolu/Owner.d.ts +129 -83
  31. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  32. package/dist/src/Evolu/Owner.js +80 -89
  33. package/dist/src/Evolu/Platform.d.ts +9 -7
  34. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  35. package/dist/src/Evolu/Protocol.d.ts +114 -191
  36. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  37. package/dist/src/Evolu/Protocol.js +409 -416
  38. package/dist/src/Evolu/Public.d.ts +6 -8
  39. package/dist/src/Evolu/Public.d.ts.map +1 -1
  40. package/dist/src/Evolu/Public.js +2 -3
  41. package/dist/src/Evolu/PublicKysely.js +3 -3
  42. package/dist/src/Evolu/Relay.d.ts +1 -2
  43. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  44. package/dist/src/Evolu/Relay.js +11 -9
  45. package/dist/src/Evolu/Schema.d.ts +88 -27
  46. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  47. package/dist/src/Evolu/Schema.js +141 -24
  48. package/dist/src/Evolu/Storage.d.ts +158 -14
  49. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  50. package/dist/src/Evolu/Storage.js +32 -32
  51. package/dist/src/Evolu/Sync.d.ts +77 -13
  52. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  53. package/dist/src/Evolu/Sync.js +453 -20
  54. package/dist/src/Evolu/Timestamp.d.ts +29 -27
  55. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  56. package/dist/src/Evolu/Timestamp.js +20 -18
  57. package/dist/src/ManyToManyMap.d.ts +74 -10
  58. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  59. package/dist/src/ManyToManyMap.js +41 -6
  60. package/dist/src/Random.d.ts +3 -2
  61. package/dist/src/Random.d.ts.map +1 -1
  62. package/dist/src/RefCountedResourceManager.d.ts +119 -0
  63. package/dist/src/RefCountedResourceManager.d.ts.map +1 -0
  64. package/dist/src/RefCountedResourceManager.js +197 -0
  65. package/dist/src/Result.d.ts +144 -22
  66. package/dist/src/Result.d.ts.map +1 -1
  67. package/dist/src/Result.js +5 -2
  68. package/dist/src/Sqlite.d.ts +20 -4
  69. package/dist/src/Sqlite.d.ts.map +1 -1
  70. package/dist/src/Sqlite.js +50 -8
  71. package/dist/src/Task.d.ts +511 -0
  72. package/dist/src/Task.d.ts.map +1 -0
  73. package/dist/src/Task.js +410 -0
  74. package/dist/src/Time.d.ts +59 -0
  75. package/dist/src/Time.d.ts.map +1 -1
  76. package/dist/src/Time.js +87 -4
  77. package/dist/src/Type.d.ts +431 -341
  78. package/dist/src/Type.d.ts.map +1 -1
  79. package/dist/src/Type.js +458 -466
  80. package/dist/src/WebSocket.d.ts +5 -2
  81. package/dist/src/WebSocket.d.ts.map +1 -1
  82. package/dist/src/WebSocket.js +12 -13
  83. package/dist/src/Worker.d.ts +39 -11
  84. package/dist/src/Worker.d.ts.map +1 -1
  85. package/dist/src/Worker.js +22 -4
  86. package/dist/src/index.d.ts +2 -3
  87. package/dist/src/index.d.ts.map +1 -1
  88. package/dist/src/index.js +2 -3
  89. package/package.json +7 -7
  90. package/src/Assert.ts +2 -4
  91. package/src/Buffer.ts +1 -1
  92. package/src/CallbackRegistry.ts +84 -0
  93. package/src/Console.ts +91 -11
  94. package/src/Crypto.ts +78 -91
  95. package/src/Evolu/Db.ts +455 -947
  96. package/src/Evolu/Diff.ts +7 -5
  97. package/src/Evolu/Evolu.ts +545 -307
  98. package/src/Evolu/Internal.ts +0 -2
  99. package/src/Evolu/LocalAuth.ts +422 -0
  100. package/src/Evolu/Owner.ts +191 -131
  101. package/src/Evolu/Platform.ts +9 -9
  102. package/src/Evolu/Protocol.ts +536 -653
  103. package/src/Evolu/Public.ts +7 -9
  104. package/src/Evolu/PublicKysely.ts +3 -3
  105. package/src/Evolu/Relay.ts +17 -12
  106. package/src/Evolu/Schema.ts +271 -66
  107. package/src/Evolu/Storage.ts +263 -55
  108. package/src/Evolu/Sync.ts +758 -37
  109. package/src/Evolu/Timestamp.ts +30 -35
  110. package/src/ManyToManyMap.ts +127 -24
  111. package/src/Random.ts +3 -2
  112. package/src/RefCountedResourceManager.ts +368 -0
  113. package/src/Result.ts +149 -23
  114. package/src/Sqlite.ts +59 -24
  115. package/src/Task.ts +779 -0
  116. package/src/Time.ts +168 -4
  117. package/src/Type.ts +657 -695
  118. package/src/WebSocket.ts +23 -17
  119. package/src/Worker.ts +72 -23
  120. package/src/index.ts +2 -3
  121. package/dist/src/Callbacks.d.ts +0 -20
  122. package/dist/src/Callbacks.d.ts.map +0 -1
  123. package/dist/src/Callbacks.js +0 -18
  124. package/dist/src/Evolu/Config.d.ts +0 -82
  125. package/dist/src/Evolu/Config.d.ts.map +0 -1
  126. package/dist/src/Evolu/Config.js +0 -9
  127. package/dist/src/Evolu/Kysely.d.ts +0 -6
  128. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  129. package/dist/src/Evolu/Kysely.js +0 -21
  130. package/dist/src/NanoId.d.ts +0 -27
  131. package/dist/src/NanoId.d.ts.map +0 -1
  132. package/dist/src/NanoId.js +0 -6
  133. package/dist/src/Promise.d.ts +0 -180
  134. package/dist/src/Promise.d.ts.map +0 -1
  135. package/dist/src/Promise.js +0 -176
  136. package/src/Callbacks.ts +0 -43
  137. package/src/Evolu/Config.ts +0 -97
  138. package/src/Evolu/Kysely.ts +0 -38
  139. package/src/NanoId.ts +0 -39
  140. package/src/Promise.ts +0 -295
@@ -4,15 +4,22 @@
4
4
  *
5
5
  * Evolu Protocol is a local-first, end-to-end encrypted binary synchronization
6
6
  * protocol optimized for minimal size and maximum speed. It enables data sync
7
- * between a client and a relay, clients in a peer-to-peer (P2P) setup, or
8
- * relays with each other.
7
+ * between a client and a relay. In the future, direct peer-to-peer (P2P) sync
8
+ * between clients will be possible without a relay.
9
+ *
10
+ * Relays don't need to sync with each other—clients using those relays will
11
+ * sync them eventually. If a relay is offline (e.g., for maintenance), it will
12
+ * sync automatically later via client sync logic. For relay backup using
13
+ * SQLite, see https://sqlite.org/rsync.html (uses a similar algorithm to Evolu
14
+ * RBSR).
9
15
  *
10
16
  * Evolu Protocol is designed for SQLite but can be extended to any database. It
11
17
  * implements [Range-Based Set
12
18
  * Reconciliation](https://arxiv.org/abs/2212.13567). To learn how RBSR works,
13
19
  * check [Negentropy](https://logperiodic.com/rbsr.html). Evolu Protocol is
14
20
  * similar to Negentropy but uses different encoding and also provides data
15
- * transfer and ownership.
21
+ * transfer, ownership, real-time broadcasting, request-response semantics, and
22
+ * error handling.
16
23
  *
17
24
  * ### Message Structure
18
25
  *
@@ -21,12 +28,15 @@
21
28
  * | **Header** | |
22
29
  * | - {@link protocolVersion} | |
23
30
  * | - {@link OwnerId} | {@link Owner} |
24
- * | **Initiator** | |
25
- * | - {@link WriteKeyMode} | |
26
- * | - {@link WriteKey} | If WriteKeyMode >= 1 |
27
- * | - {@link WriteKey} | If WriteKeyMode = 2 (new) |
28
- * | **Non-initiator** | |
31
+ * | - messageType | {@link MessageType} |
32
+ * | **Request (messageType=0)** | |
33
+ * | - hasWriteKey | 0 = no, 1 = yes |
34
+ * | - {@link OwnerWriteKey} | If hasWriteKey = 1 |
35
+ * | - subscriptionFlag | {@link SubscriptionFlags} |
36
+ * | **Response (messageType=1)** | |
29
37
  * | - {@link ProtocolErrorCode} | |
38
+ * | **Broadcast (messageType=2)** | |
39
+ * | - (no additional fields) | |
30
40
  * | **Messages** | |
31
41
  * | - {@link NonNegativeInt} | A number of messages. |
32
42
  * | - {@link EncryptedCrdtMessage} | |
@@ -36,19 +46,12 @@
36
46
  *
37
47
  * ### WriteKey Validation
38
48
  *
39
- * The initiator sends WriteKeyMode and optionally one or two WriteKeys. One key
40
- * for write operations and two for key rotation (current and new). Note that
41
- * it's ok to not send any key if initiator is going to be synced with readonly
42
- * owner. The non-initiator validates them immediately after parsing the
43
- * initiator header, before processing any messages or ranges.
44
- *
45
- * ### WriteKey Rotation
46
- *
47
- * When initiator's {@link WriteKeyMode} is `Rotation`, two WriteKeys are
48
- * present:
49
- *
50
- * 1. Current WriteKey (for validation)
51
- * 2. New WriteKey (to be stored)
49
+ * The initiator sends a hasWriteKey flag and optionally a WriteKey. The
50
+ * WriteKey is required when sending messages as a secure token proving the
51
+ * initiator can write changes. It's ok to not send a WriteKey if the initiator
52
+ * is only syncing (read-only) and not sending messages. The non-initiator
53
+ * validates the WriteKey immediately after parsing the initiator header, before
54
+ * processing any messages or ranges.
52
55
  *
53
56
  * ### Synchronization
54
57
  *
@@ -64,18 +67,18 @@
64
67
  *
65
68
  * The **non-initiator always responds** to provide sync completion feedback,
66
69
  * even with empty messages containing only the header and no error. This allows
67
- * the initiator to reliably detect when synchronization is complete.
70
+ * the initiator to detect when synchronization is complete.
68
71
  *
69
72
  * Both **Messages** and **Ranges** are optional, allowing each side to send,
70
73
  * sync, or only subscribe data as needed.
71
74
  *
72
- * When the initiator sends data, the {@link WriteKey} is required in Messages as
73
- * a secure token proving the initiator can write changes. The non-initiator
74
- * responds without a {@link WriteKey}, since the initiator’s request already
75
- * signals it wants data. If the non-initiator detects an issue, it sends an
76
- * error code via the `Error` field in the header back to the initiator. In
77
- * relay-to-relay or P2P sync, both sides may require the {@link WriteKey}
78
- * depending on who is the initiator.
75
+ * When the initiator sends data, the {@link OwnerWriteKey} is required as a
76
+ * secure token proving the initiator can write changes. The non-initiator
77
+ * responds without a {@link OwnerWriteKey}, since the initiator’s request
78
+ * already signals it wants data. If the non-initiator detects an issue, it
79
+ * sends an error code via the `Error` field in the header back to the
80
+ * initiator. In relay-to-relay or P2P sync, both sides may require the
81
+ * {@link OwnerWriteKey} depending on who is the initiator.
79
82
  *
80
83
  * ### Protocol Errors
81
84
  *
@@ -89,15 +92,21 @@
89
92
  * - {@link ProtocolUnsupportedVersionError}: Protocol version mismatch.
90
93
  * - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
91
94
  *
92
- * All protocol errors except `ProtocolInvalidDataError` include the `ownerId`
95
+ * All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
93
96
  * to allow clients to associate errors with the correct owner.
94
97
  *
95
98
  * ### Message Size Limit
96
99
  *
97
100
  * The protocol enforces a strict maximum size for all messages, defined by
98
101
  * {@link maxProtocolMessageSize}. This ensures every {@link ProtocolMessage} is
99
- * less than or equal to this limit, eliminating the need for applications to
100
- * fragment and reconstruct messages during transmission.
102
+ * less than or equal to this limit, enabling stateless transports, simplified
103
+ * relay implementation, and predictable memory usage. When all messages don't
104
+ * fit within the limit, the protocol automatically continues synchronization in
105
+ * subsequent rounds using range-based reconciliation.
106
+ *
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}.
101
110
  *
102
111
  * ### Why Binary?
103
112
  *
@@ -105,13 +114,12 @@
105
114
  *
106
115
  * - Encrypted data doesn’t compress well, unlike plain JSON.
107
116
  * - Message size must be controlled during creation.
108
- * - Sequential byte reading is faster than parsing and can avoid conversions.
117
+ * - Sequential byte reading is faster than parsing and avoids conversions.
109
118
  *
110
119
  * It uses structure-aware encoding, significantly outperforming generic binary
111
120
  * serialization formats with the following optimizations:
112
121
  *
113
122
  * - **NonNegativeInt:** Up to 33% smaller than MessagePack.
114
- * - **Base64Url Strings:** Up to 25% size reduction.
115
123
  * - **DateIso:** Up to 75% smaller.
116
124
  * - **Timestamp Encoding:** Delta encoding for milliseconds and run-length
117
125
  * encoding (RLE) for counters and NodeIds.
@@ -144,10 +152,10 @@
144
152
  * @module
145
153
  */
146
154
 
147
- import { sha256 } from "@noble/hashes/sha2";
148
- import { pack, unpack, unpackMultiple } from "msgpackr";
155
+ import { Packr } from "msgpackr";
149
156
  import { isNonEmptyReadonlyArray, NonEmptyReadonlyArray } from "../Array.js";
150
157
  import { assert } from "../Assert.js";
158
+ import { Brand } from "../Brand.js";
151
159
  import {
152
160
  Buffer,
153
161
  bytesToHex,
@@ -157,9 +165,9 @@ import {
157
165
  utf8ToBytes,
158
166
  } from "../Buffer.js";
159
167
  import {
160
- CreateRandomBytesDep,
161
168
  EncryptionKey,
162
169
  padmePaddingLength,
170
+ RandomBytesDep,
163
171
  SymmetricCryptoDecryptError,
164
172
  SymmetricCryptoDep,
165
173
  } from "../Crypto.js";
@@ -170,34 +178,66 @@ import { err, ok, Result } from "../Result.js";
170
178
  import { SqliteValue } from "../Sqlite.js";
171
179
  import {
172
180
  Base64Url,
173
- base64UrlAlphabet,
174
- DateIsoString,
181
+ base64UrlToUint8Array,
182
+ DateIso,
175
183
  Id,
176
- idTypeValueLength,
177
- JsonValueFromString,
178
- maxLength,
179
- NanoId,
184
+ IdBytes,
185
+ idBytesToId,
186
+ idBytesTypeValueLength,
187
+ idToIdBytes,
188
+ Json,
189
+ jsonToJsonValue,
180
190
  NonNegativeInt,
181
191
  Number,
182
- object,
183
192
  PositiveInt,
184
- record,
193
+ uint8ArrayToBase64Url,
185
194
  } from "../Type.js";
186
195
  import { Predicate } from "../Types.js";
187
- import { Brand } from "../Brand.js";
188
- import { Owner, OwnerId, WriteKey, writeKeyLength } from "./Owner.js";
189
196
  import {
190
- BinaryTimestamp,
191
- binaryTimestampLength,
192
- binaryTimestampToTimestamp,
197
+ Owner,
198
+ OwnerId,
199
+ OwnerIdBytes,
200
+ ownerIdToOwnerIdBytes,
201
+ OwnerWriteKey,
202
+ ownerWriteKeyLength,
203
+ } from "./Owner.js";
204
+ import {
205
+ BaseRange,
206
+ CrdtMessage,
207
+ DbChange,
208
+ EncryptedCrdtMessage,
209
+ EncryptedDbChange,
210
+ Fingerprint,
211
+ FingerprintRange,
212
+ fingerprintSize,
213
+ InfiniteUpperBound,
214
+ Range,
215
+ RangeType,
216
+ RangeUpperBound,
217
+ SkipRange,
218
+ StorageDep,
219
+ TimestampsRange,
220
+ } from "./Storage.js";
221
+ import {
193
222
  Counter,
194
223
  eqTimestamp,
195
224
  Millis,
196
225
  NodeId,
197
226
  Timestamp,
198
- timestampToBinaryTimestamp,
227
+ TimestampBytes,
228
+ timestampBytesLength,
229
+ timestampBytesToTimestamp,
230
+ timestampToTimestampBytes,
199
231
  } from "./Timestamp.js";
200
232
 
233
+ /**
234
+ * MessagePack serializer for standard compatibility and compact encoding.
235
+ *
236
+ * - `variableMapSize: true` - More compact maps, ~5-10% slower encoding
237
+ * - `useRecords: false` - Standard MessagePack without extensions
238
+ */
239
+ const packr = new Packr({ variableMapSize: true, useRecords: false });
240
+
201
241
  /** Maximum size of the entire protocol message in bytes. */
202
242
  export const maxProtocolMessageSize = 1_000_000 as PositiveInt;
203
243
 
@@ -210,6 +250,29 @@ export type ProtocolMessage = Uint8Array & Brand<"ProtocolMessage">;
210
250
  /** Evolu Protocol version. */
211
251
  export const protocolVersion = 0 as NonNegativeInt;
212
252
 
253
+ export const MessageType = {
254
+ /** Request message from initiator (client) to non-initiator (relay). */
255
+ Request: 0,
256
+ /** Response message from non-initiator (relay) to initiator (client). */
257
+ Response: 1,
258
+ /** Broadcast message from non-initiator (relay) to subscribed clients. */
259
+ Broadcast: 2,
260
+ } as const;
261
+
262
+ export type MessageType = (typeof MessageType)[keyof typeof MessageType];
263
+
264
+ export const SubscriptionFlags = {
265
+ /** No subscription changes for this owner. */
266
+ None: 0,
267
+ /** Subscribe to updates for this owner. */
268
+ Subscribe: 1,
269
+ /** Unsubscribe from updates for this owner. */
270
+ Unsubscribe: 2,
271
+ } as const;
272
+
273
+ export type SubscriptionFlag =
274
+ (typeof SubscriptionFlags)[keyof typeof SubscriptionFlags];
275
+
213
276
  export const ProtocolErrorCode = {
214
277
  NoError: 0,
215
278
  /** A code for {@link ProtocolWriteKeyError}. */
@@ -223,179 +286,6 @@ export const ProtocolErrorCode = {
223
286
  type ProtocolErrorCode =
224
287
  (typeof ProtocolErrorCode)[keyof typeof ProtocolErrorCode];
225
288
 
226
- export const WriteKeyMode = {
227
- None: 0,
228
- Single: 1,
229
- Rotation: 2,
230
- } as const;
231
-
232
- type WriteKeyMode = (typeof WriteKeyMode)[keyof typeof WriteKeyMode];
233
-
234
- /**
235
- * Evolu Protocol Storage
236
- *
237
- * The protocol is agnostic to storage implementation details—any storage can be
238
- * plugged in, as long as it implements this interface. Implementations must
239
- * handle their own errors; return values only indicates overall success or
240
- * failure.
241
- */
242
- export interface Storage {
243
- readonly getSize: (ownerId: BinaryOwnerId) => NonNegativeInt | null;
244
-
245
- readonly fingerprint: (
246
- ownerId: BinaryOwnerId,
247
- begin: NonNegativeInt,
248
- end: NonNegativeInt,
249
- ) => Fingerprint | null;
250
-
251
- /**
252
- * Computes fingerprints with their upper bounds in one call.
253
- *
254
- * This function can be replaced with many fingerprint/findLowerBound calls,
255
- * but implementations can leverage it for batching and more efficient
256
- * fingerprint computation.
257
- */
258
- readonly fingerprintRanges: (
259
- ownerId: BinaryOwnerId,
260
- buckets: ReadonlyArray<NonNegativeInt>,
261
- upperBound?: RangeUpperBound,
262
- ) => ReadonlyArray<FingerprintRange> | null;
263
-
264
- readonly findLowerBound: (
265
- ownerId: BinaryOwnerId,
266
- begin: NonNegativeInt,
267
- end: NonNegativeInt,
268
- upperBound: RangeUpperBound,
269
- ) => NonNegativeInt | null;
270
-
271
- readonly iterate: (
272
- ownerId: BinaryOwnerId,
273
- begin: NonNegativeInt,
274
- end: NonNegativeInt,
275
- callback: (timestamp: BinaryTimestamp, index: NonNegativeInt) => boolean,
276
- ) => void;
277
-
278
- /** Validates the {@link WriteKey} for the given {@link Owner}. */
279
- readonly validateWriteKey: (
280
- ownerId: BinaryOwnerId,
281
- writeKey: WriteKey,
282
- ) => boolean;
283
-
284
- /** Sets the {@link WriteKey} for the given {@link Owner}. */
285
- readonly setWriteKey: (ownerId: BinaryOwnerId, writeKey: WriteKey) => boolean;
286
-
287
- /** Write encrypted {@link CrdtMessage}s to storage. */
288
- readonly writeMessages: (
289
- ownerId: BinaryOwnerId,
290
- messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
291
- ) => boolean;
292
-
293
- /** Read encrypted {@link DbChange}s from storage. */
294
- readonly readDbChange: (
295
- ownerId: BinaryOwnerId,
296
- timestamp: BinaryTimestamp,
297
- ) => EncryptedDbChange | null;
298
-
299
- /** Delete all data for the given {@link Owner}. */
300
- readonly deleteOwner: (ownerId: BinaryOwnerId) => boolean;
301
- }
302
-
303
- export interface StorageDep {
304
- readonly storage: Storage;
305
- }
306
-
307
- /** An encrypted {@link CrdtMessage}. */
308
- export interface EncryptedCrdtMessage {
309
- readonly timestamp: Timestamp;
310
- readonly change: EncryptedDbChange;
311
- }
312
-
313
- /** Encrypted DbChange */
314
- export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
315
-
316
- /**
317
- * A CRDT message that combines a unique {@link Timestamp} with a
318
- * {@link DbChange}.
319
- */
320
- export interface CrdtMessage {
321
- readonly timestamp: Timestamp;
322
- readonly change: DbChange;
323
- }
324
-
325
- /**
326
- * Base64Url string with maximum length of 256 characters. Encoding strings as
327
- * Base64UrlString saves up to 25% in size compared to regular strings.
328
- */
329
- export const Base64Url256 = maxLength(256)(Base64Url);
330
- export type Base64Url256 = typeof Base64Url256.Type;
331
-
332
- /**
333
- * A DbChange is a change to a table row. Together with a unique
334
- * {@link Timestamp}, it forms a {@link CrdtMessage}.
335
- */
336
- export const DbChange = object({
337
- table: Base64Url256,
338
- id: Id,
339
- values: record(Base64Url256, SqliteValue),
340
- });
341
- export type DbChange = typeof DbChange.Type;
342
-
343
- export const RangeType = {
344
- Fingerprint: 1,
345
- Skip: 0,
346
- Timestamps: 2,
347
- } as const;
348
-
349
- export type RangeType = (typeof RangeType)[keyof typeof RangeType];
350
-
351
- export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
352
- export type InfiniteUpperBound = typeof InfiniteUpperBound;
353
-
354
- /**
355
- * Union type for Range's upperBound: either a {@link BinaryTimestamp} or
356
- * {@link InfiniteUpperBound}.
357
- */
358
- export type RangeUpperBound = BinaryTimestamp | InfiniteUpperBound;
359
-
360
- interface BaseRange {
361
- readonly upperBound: RangeUpperBound;
362
- }
363
-
364
- export interface SkipRange extends BaseRange {
365
- readonly type: typeof RangeType.Skip;
366
- }
367
-
368
- export interface FingerprintRange extends BaseRange {
369
- readonly type: typeof RangeType.Fingerprint;
370
- readonly fingerprint: Fingerprint;
371
- }
372
-
373
- /**
374
- * A cryptographic hash used for efficiently comparing collections of
375
- * {@link BinaryTimestamp}s.
376
- *
377
- * It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
378
- * one or more timestamps.
379
- */
380
- export type Fingerprint = Uint8Array & Brand<"Fingerprint">;
381
-
382
- export const fingerprintSize = 12 as NonNegativeInt;
383
-
384
- /** A fingerprint of an empty range. */
385
- export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint;
386
-
387
- export interface TimestampsRange extends BaseRange {
388
- readonly type: typeof RangeType.Timestamps;
389
- readonly timestamps: ReadonlyArray<BinaryTimestamp>;
390
- }
391
-
392
- export interface TimestampsRangeWithTimestampsBuffer extends BaseRange {
393
- readonly type: typeof RangeType.Timestamps;
394
- readonly timestamps: TimestampsBuffer;
395
- }
396
-
397
- export type Range = SkipRange | FingerprintRange | TimestampsRange;
398
-
399
289
  export type ProtocolError =
400
290
  | ProtocolUnsupportedVersionError
401
291
  | ProtocolInvalidDataError
@@ -427,7 +317,7 @@ export interface ProtocolInvalidDataError {
427
317
  readonly error: unknown;
428
318
  }
429
319
 
430
- /** Error when a {@link WriteKey} is invalid, missing, or fails validation. */
320
+ /** Error when a {@link OwnerWriteKey} is invalid, missing, or fails validation. */
431
321
  export interface ProtocolWriteKeyError extends ProtocolErrorBase {
432
322
  readonly type: "ProtocolWriteKeyError";
433
323
  }
@@ -467,14 +357,14 @@ export interface ProtocolTimestampMismatchError {
467
357
  * unidirectional and stateless transports.
468
358
  */
469
359
  export const createProtocolMessageFromCrdtMessages =
470
- (deps: SymmetricCryptoDep & CreateRandomBytesDep) =>
360
+ (deps: RandomBytesDep & SymmetricCryptoDep) =>
471
361
  (
472
362
  owner: Owner,
473
363
  messages: NonEmptyReadonlyArray<CrdtMessage>,
474
364
  maxSize?: PositiveInt,
475
365
  ): ProtocolMessage => {
476
366
  const buffer = createProtocolMessageBuffer(owner.id, {
477
- type: "initiator",
367
+ messageType: MessageType.Request,
478
368
  totalMaxSize: maxSize ?? maxProtocolMessageSize,
479
369
  writeKey: owner.writeKey,
480
370
  });
@@ -511,7 +401,7 @@ export const createProtocolMessageFromCrdtMessages =
511
401
  * For now, using a random fingerprint avoids extra complexity and is good
512
402
  * enough for this case.
513
403
  */
514
- const randomFingerprint = deps.createRandomBytes(
404
+ const randomFingerprint = deps.randomBytes.create(
515
405
  fingerprintSize,
516
406
  ) as unknown as Fingerprint;
517
407
 
@@ -529,16 +419,22 @@ export const createProtocolMessageFromCrdtMessages =
529
419
  /** Creates a {@link ProtocolMessage} for sync. */
530
420
  export const createProtocolMessageForSync =
531
421
  (deps: StorageDep) =>
532
- (ownerId: OwnerId): ProtocolMessage | null => {
533
- const buffer = createProtocolMessageBuffer(ownerId, { type: "initiator" });
534
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
422
+ (
423
+ ownerId: OwnerId,
424
+ subscriptionFlag?: SubscriptionFlag,
425
+ ): ProtocolMessage | null => {
426
+ const buffer = createProtocolMessageBuffer(ownerId, {
427
+ messageType: MessageType.Request,
428
+ subscriptionFlag: subscriptionFlag ?? SubscriptionFlags.None,
429
+ });
430
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
535
431
 
536
- const size = deps.storage.getSize(binaryOwnerId);
432
+ const size = deps.storage.getSize(ownerIdBytes);
537
433
  // Errors are handled by the storage.
538
434
  if (size == null) return null;
539
435
 
540
436
  splitRange(deps)(
541
- binaryOwnerId,
437
+ ownerIdBytes,
542
438
  0 as NonNegativeInt,
543
439
  size,
544
440
  InfiniteUpperBound,
@@ -548,18 +444,13 @@ export const createProtocolMessageForSync =
548
444
  return buffer.unwrap();
549
445
  };
550
446
 
551
- /** Creates a ProtocolMessage for {@link WriteKey} rotation. */
552
- export const createProtocolMessageForWriteKeyRotation = (
447
+ export const createProtocolMessageForUnsubscribe = (
553
448
  ownerId: OwnerId,
554
- currentWriteKey: WriteKey,
555
- newWriteKey: WriteKey,
556
- ): ProtocolMessage => {
557
- const buffer = createProtocolMessageBuffer(ownerId, {
558
- type: "initiator",
559
- writeKey: [currentWriteKey, newWriteKey],
560
- });
561
- return buffer.unwrap();
562
- };
449
+ ): ProtocolMessage =>
450
+ createProtocolMessageBuffer(ownerId, {
451
+ messageType: MessageType.Request,
452
+ subscriptionFlag: SubscriptionFlags.Unsubscribe,
453
+ }).unwrap();
563
454
 
564
455
  /**
565
456
  * Mutable builder for constructing {@link ProtocolMessage} respecting size
@@ -593,14 +484,17 @@ export const createProtocolMessageBuffer = (
593
484
  readonly version?: NonNegativeInt;
594
485
  } & (
595
486
  | {
596
- readonly type: "initiator";
597
- /** Single key or [current, new] for rotation. */
598
- readonly writeKey?: WriteKey | readonly [WriteKey, WriteKey];
487
+ readonly messageType: typeof MessageType.Request;
488
+ readonly writeKey?: OwnerWriteKey;
489
+ readonly subscriptionFlag?: SubscriptionFlag;
599
490
  }
600
491
  | {
601
- readonly type: "non-initiator";
492
+ readonly messageType: typeof MessageType.Response;
602
493
  readonly errorCode: ProtocolErrorCode;
603
494
  }
495
+ | {
496
+ readonly messageType: typeof MessageType.Broadcast;
497
+ }
604
498
  ),
605
499
  ): ProtocolMessageBuffer => {
606
500
  const {
@@ -623,20 +517,19 @@ export const createProtocolMessageBuffer = (
623
517
  };
624
518
 
625
519
  encodeNonNegativeInt(buffers.header, version);
626
- buffers.header.extend(ownerIdToBinaryOwnerId(ownerId));
520
+ buffers.header.extend(ownerIdToOwnerIdBytes(ownerId));
521
+ buffers.header.extend([options.messageType]);
627
522
 
628
- if (options.type === "initiator") {
523
+ if (options.messageType === MessageType.Request) {
629
524
  if (!options.writeKey) {
630
- buffers.header.extend([WriteKeyMode.None]);
631
- } else if (!Array.isArray(options.writeKey)) {
632
- buffers.header.extend([WriteKeyMode.Single]);
633
- buffers.header.extend(options.writeKey as WriteKey);
525
+ buffers.header.extend([0]);
634
526
  } else {
635
- buffers.header.extend([WriteKeyMode.Rotation]);
636
- buffers.header.extend(options.writeKey[0] as WriteKey); // current
637
- buffers.header.extend(options.writeKey[1] as WriteKey); // new
527
+ buffers.header.extend([1]);
528
+ buffers.header.extend(options.writeKey);
638
529
  }
639
- } else {
530
+ const subscriptionFlag = options.subscriptionFlag ?? SubscriptionFlags.None;
531
+ buffers.header.extend([subscriptionFlag]);
532
+ } else if (options.messageType === MessageType.Response) {
640
533
  buffers.header.extend([options.errorCode]);
641
534
  }
642
535
 
@@ -719,10 +612,15 @@ export const createProtocolMessageBuffer = (
719
612
  },
720
613
 
721
614
  addRange: (range) => {
615
+ assert(
616
+ options.messageType !== MessageType.Broadcast,
617
+ "Cannot add a range into broadcast message",
618
+ );
722
619
  assert(
723
620
  !isLastRangeInfinite,
724
621
  "Cannot add a range after an InfiniteUpperBound range",
725
622
  );
623
+
726
624
  isLastRangeInfinite = range.upperBound === InfiniteUpperBound;
727
625
 
728
626
  /**
@@ -732,7 +630,7 @@ export const createProtocolMessageBuffer = (
732
630
  */
733
631
  if (range.upperBound !== InfiniteUpperBound)
734
632
  buffers.ranges.timestamps.add(
735
- binaryTimestampToTimestamp(range.upperBound),
633
+ timestampBytesToTimestamp(range.upperBound),
736
634
  );
737
635
  else {
738
636
  buffers.ranges.timestamps.addInfinite();
@@ -779,6 +677,11 @@ export const createProtocolMessageBuffer = (
779
677
  };
780
678
  };
781
679
 
680
+ export interface TimestampsRangeWithTimestampsBuffer extends BaseRange {
681
+ readonly type: typeof RangeType.Timestamps;
682
+ readonly timestamps: TimestampsBuffer;
683
+ }
684
+
782
685
  export interface TimestampsBuffer {
783
686
  readonly add: (timestamp: Timestamp) => void;
784
687
  readonly addInfinite: () => void;
@@ -880,7 +783,7 @@ const createRunLengthEncoder = <T>(
880
783
  };
881
784
 
882
785
  export interface ApplyProtocolMessageAsClientOptions {
883
- getWriteKey?: (ownerId: OwnerId) => WriteKey | null;
786
+ getWriteKey?: (ownerId: OwnerId) => OwnerWriteKey | null;
884
787
 
885
788
  /** For testing purposes only; should not be used in production. */
886
789
  version?: NonNegativeInt;
@@ -889,31 +792,53 @@ export interface ApplyProtocolMessageAsClientOptions {
889
792
  rangesMaxSize?: PositiveInt;
890
793
  }
891
794
 
795
+ /**
796
+ * Result type for {@link applyProtocolMessageAsClient} that distinguishes
797
+ * between responses to client requests and broadcast messages.
798
+ */
799
+ export type ApplyProtocolMessageAsClientResult =
800
+ | { readonly type: "response"; readonly message: ProtocolMessage }
801
+ | { readonly type: "no-response" }
802
+ | { readonly type: "broadcast" };
803
+
892
804
  export const applyProtocolMessageAsClient =
893
805
  (deps: StorageDep) =>
894
- (
806
+ async (
895
807
  inputMessage: Uint8Array,
896
- {
897
- getWriteKey,
898
- version = protocolVersion,
899
- totalMaxSize,
900
- rangesMaxSize,
901
- }: ApplyProtocolMessageAsClientOptions = {},
902
- ): Result<ProtocolMessage | null, ProtocolError> =>
903
- tryDecodeProtocolData<ProtocolMessage | null, ProtocolError>(
904
- inputMessage,
905
- (input) => {
906
- const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
907
-
908
- if (requestedVersion !== version) {
909
- return err<ProtocolUnsupportedVersionError>({
910
- type: "ProtocolUnsupportedVersionError",
911
- unsupportedVersion: requestedVersion,
912
- isInitiator: version < requestedVersion,
913
- ownerId,
914
- });
915
- }
808
+ options: ApplyProtocolMessageAsClientOptions = {},
809
+ ): Promise<
810
+ Result<
811
+ ApplyProtocolMessageAsClientResult,
812
+ | ProtocolInvalidDataError
813
+ | ProtocolSyncError
814
+ | ProtocolUnsupportedVersionError
815
+ | ProtocolWriteError
816
+ | ProtocolWriteKeyError
817
+ >
818
+ > => {
819
+ // try-catch instead of Result for performance and stacktraces
820
+ try {
821
+ const input = createBuffer(inputMessage);
822
+ const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
823
+ const version = options.version ?? protocolVersion;
916
824
 
825
+ if (requestedVersion !== version) {
826
+ return err<ProtocolUnsupportedVersionError>({
827
+ type: "ProtocolUnsupportedVersionError",
828
+ unsupportedVersion: requestedVersion,
829
+ isInitiator: version < requestedVersion,
830
+ ownerId,
831
+ });
832
+ }
833
+
834
+ const messageType = input.shift() as MessageType;
835
+ assert(
836
+ messageType === MessageType.Response ||
837
+ messageType === MessageType.Broadcast,
838
+ "Invalid MessageType",
839
+ );
840
+
841
+ if (messageType === MessageType.Response) {
917
842
  const errorCode = input.shift() as ProtocolErrorCode;
918
843
  if (errorCode !== ProtocolErrorCode.NoError) {
919
844
  switch (errorCode) {
@@ -938,36 +863,69 @@ export const applyProtocolMessageAsClient =
938
863
  );
939
864
  }
940
865
  }
866
+ }
941
867
 
942
- const messages = decodeMessages(input);
943
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
868
+ const messages = decodeMessages(input);
869
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
944
870
 
945
- if (
946
- isNonEmptyReadonlyArray(messages) &&
947
- !deps.storage.writeMessages(binaryOwnerId, messages)
948
- ) {
949
- return ok(null);
950
- }
871
+ if (
872
+ isNonEmptyReadonlyArray(messages) &&
873
+ !(await deps.storage.writeMessages(ownerIdBytes, messages))
874
+ ) {
875
+ return ok({ type: "no-response" });
876
+ }
951
877
 
952
- if (!getWriteKey) return ok(null);
953
- const writeKey = getWriteKey(ownerId);
954
- if (writeKey == null) return ok(null);
878
+ // Now: No writeKey, no sync.
879
+ // TODO: Allow to sync SharedReadonlyOwner
880
+ // Without local changes, writeKey will not be required.
881
+ // With local changes, writeKey will be required and if not provided,
882
+ // the sync will stop.
883
+ const writeKey = options.getWriteKey?.(ownerId);
884
+ if (writeKey == null) {
885
+ return ok({ type: "no-response" });
886
+ }
955
887
 
956
- const output = createProtocolMessageBuffer(ownerId, {
957
- type: "initiator",
958
- writeKey,
959
- totalMaxSize,
960
- rangesMaxSize,
961
- });
888
+ if (messageType === MessageType.Broadcast) {
889
+ return ok({ type: "broadcast" });
890
+ }
962
891
 
963
- return sync(deps)("initiator", input, output, binaryOwnerId);
964
- },
965
- );
892
+ const ranges = decodeRanges(input);
893
+
894
+ if (!isNonEmptyReadonlyArray(ranges)) {
895
+ return ok({ type: "no-response" });
896
+ }
897
+
898
+ const output = createProtocolMessageBuffer(ownerId, {
899
+ messageType: MessageType.Request,
900
+ writeKey,
901
+ totalMaxSize: options.totalMaxSize,
902
+ rangesMaxSize: options.rangesMaxSize,
903
+ });
904
+
905
+ const syncResult = sync(deps)(ranges, output, ownerIdBytes);
906
+
907
+ // Client sync error (handled via Storage) or no changes.
908
+ if (!syncResult.ok || !syncResult.value) {
909
+ return ok({ type: "no-response" });
910
+ }
911
+
912
+ return ok({ type: "response", message: output.unwrap() });
913
+ } catch (error) {
914
+ return err<ProtocolInvalidDataError>({
915
+ type: "ProtocolInvalidDataError",
916
+ data: inputMessage,
917
+ error,
918
+ });
919
+ }
920
+ };
966
921
 
967
922
  export interface ApplyProtocolMessageAsRelayOptions {
968
923
  /** To subscribe an owner for broadcasting. */
969
924
  subscribe?: (ownerId: OwnerId) => void;
970
925
 
926
+ /** To unsubscribe an owner from broadcasting. */
927
+ unsubscribe?: (ownerId: OwnerId) => void;
928
+
971
929
  /** To broadcast a protocol message to all subscribers. */
972
930
  broadcast?: (ownerId: OwnerId, message: ProtocolMessage) => void;
973
931
 
@@ -975,143 +933,166 @@ export interface ApplyProtocolMessageAsRelayOptions {
975
933
  rangesMaxSize?: PositiveInt;
976
934
  }
977
935
 
936
+ /**
937
+ * Result type for {@link applyProtocolMessageAsRelay}.
938
+ *
939
+ * Unlike {@link ApplyProtocolMessageAsClientResult}, relays always respond with
940
+ * a message to provide sync completion feedback. This ensures the initiator can
941
+ * reliably detect when synchronization is complete, even when there's nothing
942
+ * to sync. Clients may choose not to respond in certain cases (like when they
943
+ * receive broadcast messages or when they lack a write key for syncing).
944
+ */
945
+ export interface ApplyProtocolMessageAsRelayResult {
946
+ readonly type: "response";
947
+ readonly message: ProtocolMessage;
948
+ }
949
+
978
950
  export const applyProtocolMessageAsRelay =
979
951
  (deps: StorageDep) =>
980
- (
952
+ async (
981
953
  inputMessage: Uint8Array,
982
- {
983
- subscribe,
984
- broadcast,
985
- totalMaxSize,
986
- rangesMaxSize,
987
- }: ApplyProtocolMessageAsRelayOptions = {},
954
+ options: ApplyProtocolMessageAsRelayOptions = {},
988
955
  /** For testing purposes only; should not be used in production. */
989
956
  version = protocolVersion,
990
- ): Result<ProtocolMessage | null, ProtocolInvalidDataError> =>
991
- tryDecodeProtocolData(inputMessage, (input) => {
957
+ ): Promise<
958
+ Result<ApplyProtocolMessageAsRelayResult, ProtocolInvalidDataError>
959
+ > => {
960
+ // try-catch instead of Result for performance and stacktraces
961
+ try {
962
+ const input = createBuffer(inputMessage);
992
963
  const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
993
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
964
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
994
965
 
995
966
  if (requestedVersion !== version) {
996
967
  // Non-initiator responds with its version and ownerId.
997
968
  const output = createBuffer();
998
969
  encodeNonNegativeInt(output, version);
999
- output.extend(binaryOwnerId);
1000
- return ok(output.unwrap() as ProtocolMessage);
970
+ output.extend(ownerIdBytes);
971
+ return ok({
972
+ type: "response",
973
+ message: output.unwrap() as ProtocolMessage,
974
+ });
1001
975
  }
1002
976
 
1003
- subscribe?.(ownerId);
1004
-
1005
- const writeKeyMode = input.shift() as WriteKeyMode;
1006
- let writeKey: WriteKey | undefined;
1007
- let newWriteKey: WriteKey | undefined;
1008
-
1009
- if (writeKeyMode !== WriteKeyMode.None) {
1010
- writeKey = input.shiftN(writeKeyLength) as WriteKey;
1011
- switch (writeKeyMode) {
1012
- case WriteKeyMode.Single:
1013
- break;
1014
- case WriteKeyMode.Rotation:
1015
- newWriteKey = input.shiftN(writeKeyLength) as WriteKey;
1016
- break;
1017
- default:
1018
- throw new ProtocolDecodeError(
1019
- `Invalid WriteKeyMode: ${writeKeyMode}`,
1020
- );
1021
- }
977
+ const messageType = input.shift() as MessageType;
978
+ assert(messageType === MessageType.Request, "Invalid MessageType");
979
+
980
+ const hasWriteKey = input.shift();
981
+ let writeKey: OwnerWriteKey | undefined;
982
+
983
+ if (hasWriteKey === 1) {
984
+ writeKey = input.shiftN(ownerWriteKeyLength) as OwnerWriteKey;
985
+ }
986
+
987
+ const subscriptionFlag = input.shift() as SubscriptionFlag;
988
+
989
+ switch (subscriptionFlag) {
990
+ case SubscriptionFlags.Subscribe:
991
+ options.subscribe?.(ownerId);
992
+ break;
993
+ case SubscriptionFlags.Unsubscribe:
994
+ options.unsubscribe?.(ownerId);
995
+ break;
996
+ case SubscriptionFlags.None:
997
+ break;
1022
998
  }
1023
999
 
1024
1000
  if (writeKey) {
1025
- const isValid = deps.storage.validateWriteKey(binaryOwnerId, writeKey);
1001
+ const isValid = deps.storage.validateWriteKey(ownerIdBytes, writeKey);
1026
1002
  if (!isValid) {
1027
- return ok(
1028
- createProtocolMessageBuffer(ownerId, {
1029
- type: "non-initiator",
1003
+ return ok({
1004
+ type: "response",
1005
+ message: createProtocolMessageBuffer(ownerId, {
1006
+ messageType: MessageType.Response,
1030
1007
  errorCode: ProtocolErrorCode.WriteKeyError,
1031
1008
  }).unwrap(),
1032
- );
1033
- }
1034
-
1035
- if (newWriteKey) {
1036
- const rotationSuccess = deps.storage.setWriteKey(
1037
- binaryOwnerId,
1038
- newWriteKey,
1039
- );
1040
- if (!rotationSuccess) {
1041
- return ok(
1042
- createProtocolMessageBuffer(ownerId, {
1043
- type: "non-initiator",
1044
- errorCode: ProtocolErrorCode.WriteError,
1045
- }).unwrap(),
1046
- );
1047
- }
1009
+ });
1048
1010
  }
1049
1011
  }
1050
1012
 
1051
1013
  const messages = decodeMessages(input);
1052
1014
 
1053
1015
  if (isNonEmptyReadonlyArray(messages)) {
1054
- if (!writeKey)
1055
- return ok(
1056
- createProtocolMessageBuffer(ownerId, {
1057
- type: "non-initiator",
1016
+ if (!writeKey) {
1017
+ return ok({
1018
+ type: "response",
1019
+ message: createProtocolMessageBuffer(ownerId, {
1020
+ messageType: MessageType.Response,
1058
1021
  errorCode: ProtocolErrorCode.WriteKeyError,
1059
1022
  }).unwrap(),
1060
- );
1023
+ });
1024
+ }
1061
1025
 
1062
- // Only broadcast if there's no ranges.
1063
- if (broadcast && input.getLength() === 0) {
1026
+ /**
1027
+ * Broadcast messages to all subscribed devices. This ensures real-time
1028
+ * synchronization between clients.
1029
+ *
1030
+ * When a relay's database is deleted or clients migrate to a new relay
1031
+ * (without data migration), clients will sync their data to the relay,
1032
+ * and the relay will broadcast those messages to other connected
1033
+ * clients. Those clients may receive messages they already have, but
1034
+ * this is safe because `applyMessages` is idempotent. As the relay
1035
+ * becomes more synchronized with clients over time, fewer duplicate
1036
+ * messages will be broadcasted.
1037
+ */
1038
+ if (options.broadcast) {
1064
1039
  const broadcastBuffer = createProtocolMessageBuffer(ownerId, {
1065
- type: "non-initiator",
1066
- errorCode: ProtocolErrorCode.NoError,
1067
- totalMaxSize,
1068
- rangesMaxSize,
1040
+ messageType: MessageType.Broadcast,
1041
+ totalMaxSize: options.totalMaxSize,
1042
+ rangesMaxSize: options.rangesMaxSize,
1069
1043
  version,
1070
1044
  });
1071
1045
  for (const message of messages) {
1072
1046
  broadcastBuffer.addMessage(message);
1073
1047
  }
1074
- broadcast(ownerId, broadcastBuffer.unwrap());
1048
+ options.broadcast(ownerId, broadcastBuffer.unwrap());
1075
1049
  }
1076
1050
 
1077
- if (!deps.storage.writeMessages(binaryOwnerId, messages))
1078
- return ok(
1079
- createProtocolMessageBuffer(ownerId, {
1080
- type: "non-initiator",
1051
+ if (!(await deps.storage.writeMessages(ownerIdBytes, messages))) {
1052
+ return ok({
1053
+ type: "response",
1054
+ message: createProtocolMessageBuffer(ownerId, {
1055
+ messageType: MessageType.Response,
1081
1056
  errorCode: ProtocolErrorCode.WriteError,
1082
1057
  }).unwrap(),
1083
- );
1058
+ });
1059
+ }
1084
1060
  }
1085
1061
 
1062
+ const ranges = decodeRanges(input);
1063
+
1086
1064
  const output = createProtocolMessageBuffer(ownerId, {
1087
- type: "non-initiator",
1065
+ messageType: MessageType.Response,
1088
1066
  errorCode: ProtocolErrorCode.NoError,
1089
- totalMaxSize,
1090
- rangesMaxSize,
1067
+ totalMaxSize: options.totalMaxSize,
1068
+ rangesMaxSize: options.rangesMaxSize,
1091
1069
  });
1092
1070
 
1093
- return sync(deps)("non-initiator", input, output, binaryOwnerId);
1094
- });
1071
+ // Non-initiators always respond to provide sync completion feedback,
1072
+ // even when there's nothing to sync.
1073
+ if (!isNonEmptyReadonlyArray(ranges)) {
1074
+ return ok({ type: "response", message: output.unwrap() });
1075
+ }
1095
1076
 
1096
- /**
1097
- * Wraps Evolu Protocol decoding functions, which use exceptions instead of
1098
- * {@link Result} to provide stack traces for debugging and reduce allocation
1099
- * overhead in success cases.
1100
- */
1101
- const tryDecodeProtocolData = <T, E>(
1102
- data: Uint8Array,
1103
- callback: (buffer: Buffer) => Result<T, E | ProtocolInvalidDataError>,
1104
- ) => {
1105
- try {
1106
- return callback(createBuffer(data));
1107
- } catch (error: unknown) {
1108
- return err<ProtocolInvalidDataError>({
1109
- type: "ProtocolInvalidDataError",
1110
- data,
1111
- error,
1112
- });
1113
- }
1114
- };
1077
+ const syncResult = sync(deps)(ranges, output, ownerIdBytes);
1078
+
1079
+ const message = syncResult.ok
1080
+ ? output.unwrap()
1081
+ : createProtocolMessageBuffer(ownerId, {
1082
+ messageType: MessageType.Response,
1083
+ errorCode: syncResult.error,
1084
+ }).unwrap();
1085
+
1086
+ // Non-initiators always respond to provide sync completion feedback,
1087
+ return ok({ type: "response", message });
1088
+ } catch (error) {
1089
+ return err<ProtocolInvalidDataError>({
1090
+ type: "ProtocolInvalidDataError",
1091
+ data: inputMessage,
1092
+ error,
1093
+ });
1094
+ }
1095
+ };
1115
1096
 
1116
1097
  const decodeVersionAndOwner = (input: Buffer): [NonNegativeInt, OwnerId] => {
1117
1098
  // This structure must never change across protocol versions. The version
@@ -1119,7 +1100,7 @@ const decodeVersionAndOwner = (input: Buffer): [NonNegativeInt, OwnerId] => {
1119
1100
  // to enable version negotiation and owner identification before any other
1120
1101
  // processing occurs.
1121
1102
  const version = decodeNonNegativeInt(input);
1122
- const ownerId = decodeOwnerId(input);
1103
+ const ownerId = decodeId(input) as OwnerId;
1123
1104
  return [version, ownerId];
1124
1105
  };
1125
1106
 
@@ -1154,40 +1135,14 @@ const decodeMessages = (
1154
1135
  const sync =
1155
1136
  (deps: StorageDep) =>
1156
1137
  (
1157
- role: "initiator" | "non-initiator",
1158
- input: Buffer,
1138
+ ranges: NonEmptyReadonlyArray<Range>,
1159
1139
  output: ProtocolMessageBuffer,
1160
- ownerId: BinaryOwnerId,
1161
- ): Result<ProtocolMessage | null, never> => {
1162
- const ranges = decodeRanges(input);
1163
-
1164
- if (!isNonEmptyReadonlyArray(ranges)) {
1165
- // Non-initiators always respond to provide sync completion feedback,
1166
- // even when there's nothing to sync.
1167
- if (role === "non-initiator") {
1168
- return ok(output.unwrap());
1169
- }
1170
- // Nothing to sync.
1171
- return ok(null);
1172
- }
1173
-
1174
- const binaryOwnerId = binaryOwnerIdToOwnerId(ownerId);
1140
+ ownerIdBytes: OwnerIdBytes,
1141
+ ): Result<boolean, typeof ProtocolErrorCode.SyncError> => {
1175
1142
  const outputInitialSize = output.getSize();
1176
1143
 
1177
- const syncFail = () => {
1178
- // Only the relay (non-initiator) reports sync errors, not the client (initiator).
1179
- if (role === "initiator") {
1180
- return ok(null);
1181
- }
1182
- const message = createProtocolMessageBuffer(binaryOwnerId, {
1183
- type: "non-initiator",
1184
- errorCode: ProtocolErrorCode.SyncError,
1185
- });
1186
- return ok(message.unwrap());
1187
- };
1188
-
1189
- const storageSize = deps.storage.getSize(ownerId);
1190
- if (storageSize == null) return syncFail();
1144
+ const storageSize = deps.storage.getSize(ownerIdBytes);
1145
+ if (storageSize == null) return err(ProtocolErrorCode.SyncError);
1191
1146
 
1192
1147
  let prevUpperBound: RangeUpperBound | null = null;
1193
1148
  let prevIndex = 0 as NonNegativeInt;
@@ -1227,7 +1182,11 @@ const sync =
1227
1182
  const addFingerprintForRemainingRange = (
1228
1183
  begin: NonNegativeInt,
1229
1184
  ): boolean => {
1230
- const fingerprint = deps.storage.fingerprint(ownerId, begin, storageSize);
1185
+ const fingerprint = deps.storage.fingerprint(
1186
+ ownerIdBytes,
1187
+ begin,
1188
+ storageSize,
1189
+ );
1231
1190
  if (!fingerprint) return false;
1232
1191
  // There is always a space for a ramaining range.
1233
1192
  output.addRange({
@@ -1243,12 +1202,12 @@ const sync =
1243
1202
 
1244
1203
  const lower = prevIndex;
1245
1204
  let upper = deps.storage.findLowerBound(
1246
- ownerId,
1205
+ ownerIdBytes,
1247
1206
  prevIndex,
1248
1207
  storageSize,
1249
1208
  currentUpperBound,
1250
1209
  );
1251
- if (upper == null) return syncFail();
1210
+ if (upper == null) return err(ProtocolErrorCode.SyncError);
1252
1211
 
1253
1212
  switch (range.type) {
1254
1213
  case RangeType.Skip: {
@@ -1258,11 +1217,11 @@ const sync =
1258
1217
 
1259
1218
  case RangeType.Fingerprint: {
1260
1219
  const ourFingerprint = deps.storage.fingerprint(
1261
- ownerId,
1220
+ ownerIdBytes,
1262
1221
  lower,
1263
1222
  upper,
1264
1223
  );
1265
- if (ourFingerprint == null) return syncFail();
1224
+ if (ourFingerprint == null) return err(ProtocolErrorCode.SyncError);
1266
1225
 
1267
1226
  if (eqArrayNumber(range.fingerprint, ourFingerprint)) {
1268
1227
  skipRange(range);
@@ -1270,15 +1229,16 @@ const sync =
1270
1229
  if (output.canSplitRange()) {
1271
1230
  coalesceSkipsBeforeAdd();
1272
1231
  splitRange(deps)(
1273
- ownerId,
1232
+ ownerIdBytes,
1274
1233
  lower,
1275
1234
  upper,
1276
1235
  currentUpperBound,
1277
1236
  output,
1278
1237
  );
1279
1238
  } else {
1280
- if (!addFingerprintForRemainingRange(upper)) return syncFail();
1281
- return ok(output.unwrap());
1239
+ return addFingerprintForRemainingRange(upper)
1240
+ ? ok(true)
1241
+ : err(ProtocolErrorCode.SyncError);
1282
1242
  }
1283
1243
  }
1284
1244
  break;
@@ -1292,45 +1252,53 @@ const sync =
1292
1252
  );
1293
1253
  const ourTimestamps = createTimestampsBuffer();
1294
1254
 
1295
- let storageError = false as boolean;
1255
+ let cantReadDbChange = false as boolean;
1296
1256
  let exceeded = false as boolean;
1297
1257
 
1298
- deps.storage.iterate(ownerId, lower, upper, (timestamp, index) => {
1299
- const timestampString = timestamp.join();
1300
- const timestampBinary = binaryTimestampToTimestamp(timestamp);
1301
-
1302
- let message: EncryptedCrdtMessage | null = null;
1258
+ deps.storage.iterate(
1259
+ ownerIdBytes,
1260
+ lower,
1261
+ upper,
1262
+ (timestamp, index) => {
1263
+ const timestampString = timestamp.join();
1264
+ const timestampBinary = timestampBytesToTimestamp(timestamp);
1265
+
1266
+ let message: EncryptedCrdtMessage | null = null;
1267
+
1268
+ if (timestampsWeNeed.has(timestampString)) {
1269
+ timestampsWeNeed.delete(timestampString);
1270
+ } else {
1271
+ const dbChange = deps.storage.readDbChange(
1272
+ ownerIdBytes,
1273
+ timestamp,
1274
+ );
1275
+ if (dbChange == null) {
1276
+ cantReadDbChange = true;
1277
+ return false;
1278
+ }
1279
+ message = {
1280
+ timestamp: timestampBinary,
1281
+ change: dbChange,
1282
+ };
1283
+ }
1303
1284
 
1304
- if (timestampsWeNeed.has(timestampString)) {
1305
- timestampsWeNeed.delete(timestampString);
1306
- } else {
1307
- const dbChange = deps.storage.readDbChange(ownerId, timestamp);
1308
- if (dbChange == null) {
1309
- storageError = true;
1285
+ if (
1286
+ !output.canAddTimestampsRangeAndMessage(ourTimestamps, message)
1287
+ ) {
1288
+ exceeded = true;
1289
+ endBound = timestamp;
1290
+ upper = index;
1310
1291
  return false;
1311
1292
  }
1312
- message = {
1313
- timestamp: timestampBinary,
1314
- change: dbChange,
1315
- };
1316
- }
1317
-
1318
- if (
1319
- !output.canAddTimestampsRangeAndMessage(ourTimestamps, message)
1320
- ) {
1321
- exceeded = true;
1322
- endBound = timestamp;
1323
- upper = index;
1324
- return false;
1325
- }
1326
1293
 
1327
- ourTimestamps.add(timestampBinary);
1328
- if (message) output.addMessage(message);
1329
- return true;
1330
- });
1294
+ ourTimestamps.add(timestampBinary);
1295
+ if (message) output.addMessage(message);
1296
+ return true;
1297
+ },
1298
+ );
1331
1299
 
1332
- if (storageError) {
1333
- return syncFail();
1300
+ if (cantReadDbChange) {
1301
+ return err(ProtocolErrorCode.SyncError);
1334
1302
  }
1335
1303
 
1336
1304
  const addRange = () => {
@@ -1345,9 +1313,9 @@ const sync =
1345
1313
  if (exceeded) {
1346
1314
  addRange();
1347
1315
  if (!addFingerprintForRemainingRange(upper)) {
1348
- return syncFail();
1316
+ return err(ProtocolErrorCode.SyncError);
1349
1317
  }
1350
- return ok(output.unwrap());
1318
+ return ok(true);
1351
1319
  }
1352
1320
 
1353
1321
  // If we need something, we have to respond with our timestamps.
@@ -1368,19 +1336,13 @@ const sync =
1368
1336
  // If all ranges were skipped, there are no changes and sync is complete.
1369
1337
  const hasChange = output.getSize() > outputInitialSize;
1370
1338
 
1371
- // Non-initiators always respond to provide sync completion feedback,
1372
- // even with empty messages. This allows clients to detect sync completion.
1373
- if (role === "non-initiator" && !hasChange) {
1374
- return ok(output.unwrap());
1375
- }
1376
-
1377
- return ok(hasChange ? output.unwrap() : null);
1339
+ return ok(hasChange);
1378
1340
  };
1379
1341
 
1380
1342
  const splitRange =
1381
1343
  (deps: StorageDep) =>
1382
1344
  (
1383
- ownerId: BinaryOwnerId,
1345
+ ownerId: OwnerIdBytes,
1384
1346
  lower: NonNegativeInt,
1385
1347
  upper: NonNegativeInt,
1386
1348
  upperBound: RangeUpperBound,
@@ -1401,7 +1363,7 @@ const splitRange =
1401
1363
  0 as NonNegativeInt,
1402
1364
  itemCount,
1403
1365
  (timestamp) => {
1404
- range.timestamps.add(binaryTimestampToTimestamp(timestamp));
1366
+ range.timestamps.add(timestampBytesToTimestamp(timestamp));
1405
1367
  return true;
1406
1368
  },
1407
1369
  );
@@ -1460,7 +1422,7 @@ const decodeRanges = (buffer: Buffer): ReadonlyArray<Range> => {
1460
1422
  for (let i = 0; i < rangesCount; i++) {
1461
1423
  const upperBound =
1462
1424
  i < timestampsCount
1463
- ? timestampToBinaryTimestamp(timestamps[i])
1425
+ ? timestampToTimestampBytes(timestamps[i])
1464
1426
  : InfiniteUpperBound;
1465
1427
 
1466
1428
  const rangeType = rangeTypes[i];
@@ -1482,7 +1444,7 @@ const decodeRanges = (buffer: Buffer): ReadonlyArray<Range> => {
1482
1444
 
1483
1445
  case RangeType.Timestamps: {
1484
1446
  const timestamps = decodeTimestamps(buffer).map(
1485
- timestampToBinaryTimestamp,
1447
+ timestampToTimestampBytes,
1486
1448
  );
1487
1449
  ranges.push({
1488
1450
  type: RangeType.Timestamps,
@@ -1550,120 +1512,31 @@ const decodeTimestamps = (
1550
1512
  return timestamps;
1551
1513
  };
1552
1514
 
1553
- /** Binary representation of {@link Id}. */
1554
- export type BinaryId = Uint8Array & Brand<"BinaryId">;
1555
-
1556
- export const binaryIdLength = 16 as NonNegativeInt;
1557
-
1558
- export const idToBinaryId = (id: Id): BinaryId =>
1559
- base64Url256ToBytes(id) as BinaryId;
1560
-
1561
- export const binaryIdToId = (binaryId: BinaryId): Id =>
1562
- decodeId(createBuffer(binaryId));
1563
-
1564
- /** Binary representation of {@link OwnerId}. */
1565
- export type BinaryOwnerId = Uint8Array & Brand<"BinaryOwnerId">;
1566
-
1567
- export const ownerIdToBinaryOwnerId = (ownerId: OwnerId): BinaryOwnerId =>
1568
- base64Url256ToBytes(ownerId) as BinaryOwnerId;
1569
-
1570
- export const binaryOwnerIdToOwnerId = (binaryOwnerId: BinaryOwnerId): OwnerId =>
1571
- decodeOwnerId(createBuffer(binaryOwnerId));
1572
-
1573
- /**
1574
- * Union type for all variants of Base64Url strings with limited length. All
1575
- * these types use Base64Url alphabet and are < 256 characters.
1576
- */
1577
- export type Base64Url256Variant = Base64Url256 | Id | NanoId | OwnerId;
1578
-
1579
- /**
1580
- * Converts a Base64Url string to a Uint8Array for binary storage. This encoding
1581
- * is more space-efficient than UTF-8 for Base64Url strings.
1582
- */
1583
- export const base64Url256ToBytes = (
1584
- string: Base64Url256Variant,
1585
- ): globalThis.Uint8Array => {
1586
- const totalBits = string.length * 6; // 6 bits per character
1587
- const byteLength = Math.ceil(totalBits / 8);
1588
- const value = new globalThis.Uint8Array(byteLength);
1589
-
1590
- let bitBuffer = 0;
1591
- let bitsInBuffer = 0;
1592
- let byteIndex = 0;
1593
-
1594
- for (const char of string) {
1595
- const charValue = base64UrlAlphabet.indexOf(char);
1596
- bitBuffer = (bitBuffer << 6) | charValue;
1597
- bitsInBuffer += 6;
1598
- while (bitsInBuffer >= 8) {
1599
- bitsInBuffer -= 8;
1600
- value[byteIndex++] = (bitBuffer >> bitsInBuffer) & 0xff;
1601
- }
1602
- }
1603
-
1604
- if (bitsInBuffer > 0 && byteIndex < byteLength) {
1605
- value[byteIndex] = (bitBuffer << (8 - bitsInBuffer)) & 0xff;
1606
- }
1607
-
1608
- return value;
1609
- };
1610
-
1611
- export const decodeBase64Url256 = (
1612
- buffer: Buffer,
1613
- stringLength: number,
1614
- ): Base64Url256Variant => {
1615
- const bytes = buffer.shiftN(
1616
- Math.ceil((stringLength * 6) / 8) as NonNegativeInt,
1617
- );
1618
-
1619
- let bitBuffer = 0;
1620
- let bitsInBuffer = 0;
1621
- let string = "";
1622
-
1623
- for (const byte of bytes) {
1624
- bitBuffer = (bitBuffer << 8) | byte;
1625
- bitsInBuffer += 8;
1626
- while (bitsInBuffer >= 6) {
1627
- bitsInBuffer -= 6;
1628
- if (string.length < stringLength) {
1629
- const charValue = (bitBuffer >> bitsInBuffer) & 0x3f;
1630
- if (charValue < 0 || charValue >= base64UrlAlphabet.length) {
1631
- throw new ProtocolDecodeError("invalid charValue");
1632
- }
1633
- string += base64UrlAlphabet[charValue];
1634
- }
1635
- }
1636
- }
1637
-
1638
- const result = Base64Url256.from(string);
1639
- if (!result.ok) throw new ProtocolDecodeError(result.error.type);
1640
-
1641
- return result.value;
1515
+ const decodeId = (buffer: Buffer): Id => {
1516
+ const bytes = buffer.shiftN(idBytesTypeValueLength);
1517
+ return idBytesToId(bytes as IdBytes);
1642
1518
  };
1643
1519
 
1644
- const decodeId = (buffer: Buffer): Id =>
1645
- decodeBase64Url256(buffer, idTypeValueLength) as Id;
1646
-
1647
- /** Not all 16 bytes are valid {@link OwnerId}. */
1648
- const decodeOwnerId = (buffer: Buffer): OwnerId => decodeId(buffer) as OwnerId;
1649
-
1650
1520
  /**
1651
1521
  * Evolu uses MessagePack to handle all number variants except for
1652
1522
  * NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
1653
1523
  */
1654
1524
  export const encodeNumber = (buffer: Buffer, number: number): void => {
1655
- buffer.extend(pack(number));
1525
+ buffer.extend(packr.pack(number));
1656
1526
  };
1657
1527
 
1658
1528
  export const decodeNumber = (buffer: Buffer): number => {
1659
1529
  let number: unknown;
1660
1530
  let end: unknown;
1661
1531
 
1662
- unpackMultiple(buffer.unwrap(), (n, _, e) => {
1663
- number = n;
1664
- end = e;
1665
- return false;
1666
- });
1532
+ packr.unpackMultiple(
1533
+ buffer.unwrap(),
1534
+ (n: unknown, _: unknown, e: unknown) => {
1535
+ number = n;
1536
+ end = e;
1537
+ return false;
1538
+ },
1539
+ );
1667
1540
 
1668
1541
  const endResult = NonNegativeInt.fromUnknown(end);
1669
1542
  if (!endResult.ok) throw new ProtocolDecodeError(endResult.error.type);
@@ -1675,13 +1548,6 @@ export const decodeNumber = (buffer: Buffer): number => {
1675
1548
  return numberResult.value;
1676
1549
  };
1677
1550
 
1678
- export const binaryTimestampToFingerprint = (
1679
- timestamp: BinaryTimestamp,
1680
- ): Fingerprint => {
1681
- const hash = sha256(timestamp).slice(0, fingerprintSize);
1682
- return hash as Fingerprint;
1683
- };
1684
-
1685
1551
  /**
1686
1552
  * Encodes and encrypts a {@link DbChange} using the provided owner's encryption
1687
1553
  * key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
@@ -1700,15 +1566,15 @@ export const encodeAndEncryptDbChange =
1700
1566
  encodeNonNegativeInt(buffer, protocolVersion);
1701
1567
 
1702
1568
  // Encode the timestamp (after version) for tamper verification
1703
- const binaryTimestamp = timestampToBinaryTimestamp(message.timestamp);
1704
- buffer.extend(binaryTimestamp);
1569
+ const timestampBytes = timestampToTimestampBytes(message.timestamp);
1570
+ buffer.extend(timestampBytes);
1705
1571
 
1706
- encodeBase64Url256(buffer, change.table);
1572
+ encodeString(buffer, change.table);
1707
1573
 
1708
- buffer.extend(idToBinaryId(change.id));
1574
+ buffer.extend(idToIdBytes(change.id));
1709
1575
 
1710
1576
  const entries = objectToEntries(change.values).map(
1711
- ([column, value]): [Base64Url256, SqliteValue] => {
1577
+ ([column, value]): [string, SqliteValue] => {
1712
1578
  return [column, value];
1713
1579
  },
1714
1580
  );
@@ -1716,7 +1582,7 @@ export const encodeAndEncryptDbChange =
1716
1582
  encodeLength(buffer, entries);
1717
1583
 
1718
1584
  for (const [column, value] of entries) {
1719
- encodeBase64Url256(buffer, column);
1585
+ encodeString(buffer, column);
1720
1586
  encodeSqliteValue(buffer, value);
1721
1587
  }
1722
1588
 
@@ -1752,11 +1618,10 @@ export const decryptAndDecodeDbChange =
1752
1618
  | SymmetricCryptoDecryptError
1753
1619
  | ProtocolInvalidDataError
1754
1620
  | ProtocolTimestampMismatchError
1755
- > =>
1756
- tryDecodeProtocolData<
1757
- DbChange,
1758
- SymmetricCryptoDecryptError | ProtocolTimestampMismatchError
1759
- >(message.change, (buffer) => {
1621
+ > => {
1622
+ // try-catch instead of Result for performance and stacktraces
1623
+ try {
1624
+ const buffer = createBuffer(message.change);
1760
1625
  const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1761
1626
 
1762
1627
  const ciphertextLength = decodeLength(buffer);
@@ -1776,11 +1641,9 @@ export const decryptAndDecodeDbChange =
1776
1641
  decodeNonNegativeInt(buffer);
1777
1642
 
1778
1643
  // Decode and verify the embedded timestamp
1779
- const embeddedBinaryTimestamp = buffer.shiftN(
1780
- binaryTimestampLength,
1781
- ) as BinaryTimestamp;
1782
- const embeddedTimestamp = binaryTimestampToTimestamp(
1783
- embeddedBinaryTimestamp,
1644
+ const embeddedTimestampBytes = buffer.shiftN(timestampBytesLength);
1645
+ const embeddedTimestamp = timestampBytesToTimestamp(
1646
+ embeddedTimestampBytes as TimestampBytes,
1784
1647
  );
1785
1648
 
1786
1649
  // Verify timestamp integrity
@@ -1792,14 +1655,14 @@ export const decryptAndDecodeDbChange =
1792
1655
  });
1793
1656
  }
1794
1657
 
1795
- const table = decodeBase64Url256WithLength(buffer);
1658
+ const table = decodeString(buffer);
1796
1659
  const id = decodeId(buffer);
1797
1660
 
1798
1661
  const length = decodeLength(buffer);
1799
1662
  const values = Object.create(null) as Record<string, SqliteValue>;
1800
1663
 
1801
1664
  for (let i = 0; i < length; i++) {
1802
- const column = decodeBase64Url256WithLength(buffer);
1665
+ const column = decodeString(buffer);
1803
1666
  const value = decodeSqliteValue(buffer);
1804
1667
  values[column] = value;
1805
1668
  }
@@ -1807,7 +1670,14 @@ export const decryptAndDecodeDbChange =
1807
1670
  const dbChange = { table, id, values };
1808
1671
 
1809
1672
  return ok(dbChange);
1810
- });
1673
+ } catch (error) {
1674
+ return err<ProtocolInvalidDataError>({
1675
+ type: "ProtocolInvalidDataError",
1676
+ data: message.change,
1677
+ error,
1678
+ });
1679
+ }
1680
+ };
1811
1681
 
1812
1682
  /**
1813
1683
  * Encodes a non-negative integer into a variable-length integer format. It's
@@ -1891,19 +1761,6 @@ export const decodeNodeId = (buffer: Buffer): NodeId => {
1891
1761
  return bytesToHex(bytes) as NodeId;
1892
1762
  };
1893
1763
 
1894
- export const encodeBase64Url256 = (
1895
- buffer: Buffer,
1896
- string: Base64Url256Variant,
1897
- ): void => {
1898
- encodeLength(buffer, string);
1899
- buffer.extend(base64Url256ToBytes(string));
1900
- };
1901
-
1902
- export const decodeBase64Url256WithLength = (buffer: Buffer): Base64Url256 => {
1903
- const length = decodeLength(buffer);
1904
- return decodeBase64Url256(buffer, length) as Base64Url256;
1905
- };
1906
-
1907
1764
  // Small ints are encoded into ProtocolValueType, saving one byte per int.
1908
1765
  const isSmallInt: Predicate<number> = (value: number) =>
1909
1766
  value >= 0 && value < 20;
@@ -1915,20 +1772,23 @@ export const ProtocolValueType = {
1915
1772
  String: 20 as NonNegativeInt,
1916
1773
  Number: 21 as NonNegativeInt,
1917
1774
  Null: 22 as NonNegativeInt,
1918
- Binary: 23 as NonNegativeInt,
1775
+ Bytes: 23 as NonNegativeInt,
1919
1776
  // We can add more types for other DBs or anything else later.
1920
1777
 
1921
1778
  // Optimized types
1922
- Id: 30 as NonNegativeInt,
1923
- Base64Url256: 31 as NonNegativeInt,
1924
- NonNegativeInt: 32 as NonNegativeInt,
1925
- Json: 33 as NonNegativeInt,
1779
+ NonNegativeInt: 30 as NonNegativeInt,
1780
+
1781
+ // String optimizations
1782
+ EmptyString: 31 as NonNegativeInt, // 1 byte vs 2 bytes (50% reduction)
1783
+ Base64Url: 32 as NonNegativeInt,
1784
+ Id: 33 as NonNegativeInt,
1785
+ Json: 34 as NonNegativeInt,
1926
1786
 
1927
1787
  // new Date().toISOString() - 24 bytes
1928
1788
  // encoded with fixed length - 8 bytes
1929
1789
  // encode as NonNegativeInt - 6 bytes (additional 25% reduction)
1930
- DateIsoWithNonNegativeTime: 34 as NonNegativeInt,
1931
- DateIsoWithNegativeTime: 35 as NonNegativeInt, // 9 bytes
1790
+ DateIsoWithNonNegativeTime: 35 as NonNegativeInt,
1791
+ DateIsoWithNegativeTime: 36 as NonNegativeInt, // 9 bytes
1932
1792
 
1933
1793
  // TODO: Operations (from 40)
1934
1794
  // Increment, Decrement, Patch, whatever.
@@ -1942,9 +1802,14 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
1942
1802
 
1943
1803
  switch (typeof value) {
1944
1804
  case "string": {
1945
- const dateIsoString = DateIsoString.from(value);
1946
- if (dateIsoString.ok) {
1947
- const time = new Date(dateIsoString.value).getTime();
1805
+ if (value === "") {
1806
+ encodeNonNegativeInt(buffer, ProtocolValueType.EmptyString);
1807
+ return;
1808
+ }
1809
+
1810
+ const dateIso = DateIso.fromParent(value);
1811
+ if (dateIso.ok) {
1812
+ const time = new Date(dateIso.value).getTime();
1948
1813
  if (NonNegativeInt.is(time)) {
1949
1814
  encodeNonNegativeInt(
1950
1815
  buffer,
@@ -1961,27 +1826,34 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
1961
1826
  return;
1962
1827
  }
1963
1828
 
1964
- const base64Url256 = Base64Url256.from(value);
1965
- if (base64Url256.ok) {
1966
- if (base64Url256.value.length === idTypeValueLength) {
1967
- encodeNonNegativeInt(buffer, ProtocolValueType.Id);
1968
- buffer.extend(base64Url256ToBytes(base64Url256.value));
1969
- return;
1970
- }
1971
- encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url256);
1972
- encodeBase64Url256(buffer, base64Url256.value);
1829
+ const id = Id.fromParent(value);
1830
+ if (id.ok) {
1831
+ encodeNonNegativeInt(buffer, ProtocolValueType.Id);
1832
+ buffer.extend(idToIdBytes(id.value));
1973
1833
  return;
1974
1834
  }
1975
1835
 
1976
- const jsonValue = JsonValueFromString.fromParent(value);
1977
- if (jsonValue.ok) {
1978
- const jsonBytes = pack(jsonValue.value);
1836
+ const json = Json.fromParent(value);
1837
+ // Only encode as Json if it survives JSON.parse/JSON.stringify round-trip.
1838
+ // Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
1839
+ // which would cause data corruption if we don't verify round-trip safety.
1840
+ if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
1841
+ const jsonBytes = packr.pack(jsonToJsonValue(json.value));
1979
1842
  encodeNonNegativeInt(buffer, ProtocolValueType.Json);
1980
1843
  encodeLength(buffer, jsonBytes);
1981
1844
  buffer.extend(jsonBytes);
1982
1845
  return;
1983
1846
  }
1984
1847
 
1848
+ const base64Url = Base64Url.fromParent(value);
1849
+ if (base64Url.ok) {
1850
+ encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url);
1851
+ const bytes = base64UrlToUint8Array(base64Url.value);
1852
+ encodeLength(buffer, bytes);
1853
+ buffer.extend(bytes);
1854
+ return;
1855
+ }
1856
+
1985
1857
  encodeNonNegativeInt(buffer, ProtocolValueType.String);
1986
1858
  encodeString(buffer, value);
1987
1859
  return;
@@ -2003,7 +1875,7 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
2003
1875
  }
2004
1876
  }
2005
1877
 
2006
- encodeNonNegativeInt(buffer, ProtocolValueType.Binary);
1878
+ encodeNonNegativeInt(buffer, ProtocolValueType.Bytes);
2007
1879
  encodeLength(buffer, value);
2008
1880
  buffer.extend(value);
2009
1881
  };
@@ -2018,39 +1890,50 @@ export const decodeSqliteValue = (buffer: Buffer): SqliteValue => {
2018
1890
  switch (type) {
2019
1891
  case ProtocolValueType.String:
2020
1892
  return decodeString(buffer);
1893
+
2021
1894
  case ProtocolValueType.Number:
2022
1895
  return decodeNumber(buffer);
1896
+
2023
1897
  case ProtocolValueType.Null:
2024
1898
  return null;
2025
- case ProtocolValueType.Binary: {
1899
+
1900
+ case ProtocolValueType.Bytes: {
2026
1901
  const length = decodeLength(buffer);
2027
1902
  return buffer.shiftN(length);
2028
1903
  }
2029
- case ProtocolValueType.Id: {
1904
+
1905
+ case ProtocolValueType.Id:
2030
1906
  return decodeId(buffer);
2031
- }
2032
- case ProtocolValueType.Base64Url256:
2033
- return decodeBase64Url256WithLength(buffer);
1907
+
2034
1908
  case ProtocolValueType.NonNegativeInt:
2035
1909
  return decodeNonNegativeInt(buffer);
1910
+
2036
1911
  case ProtocolValueType.Json: {
2037
1912
  const length = decodeLength(buffer);
2038
1913
  const bytes = buffer.shiftN(length);
2039
- return JSON.stringify(unpack(bytes));
1914
+ return JSON.stringify(packr.unpack(bytes));
2040
1915
  }
1916
+
2041
1917
  case ProtocolValueType.DateIsoWithNonNegativeTime:
2042
1918
  case ProtocolValueType.DateIsoWithNegativeTime: {
2043
1919
  const time =
2044
1920
  type === ProtocolValueType.DateIsoWithNonNegativeTime
2045
1921
  ? decodeNonNegativeInt(buffer)
2046
1922
  : decodeNumber(buffer);
2047
- const dateIsoString = DateIsoString.fromParent(
2048
- new Date(time).toISOString(),
2049
- );
2050
- if (!dateIsoString.ok)
2051
- throw new ProtocolDecodeError(dateIsoString.error.type);
2052
- return dateIsoString.value;
1923
+ const dateIso = DateIso.fromParent(new Date(time).toISOString());
1924
+ if (!dateIso.ok) throw new ProtocolDecodeError(dateIso.error.type);
1925
+ return dateIso.value;
1926
+ }
1927
+
1928
+ case ProtocolValueType.EmptyString:
1929
+ return "";
1930
+
1931
+ case ProtocolValueType.Base64Url: {
1932
+ const length = decodeLength(buffer);
1933
+ const bytes = buffer.shiftN(length);
1934
+ return uint8ArrayToBase64Url(bytes);
2053
1935
  }
1936
+
2054
1937
  default:
2055
1938
  throw new ProtocolDecodeError("invalid ProtocolValueType");
2056
1939
  }