@evolu/common 6.0.1-preview.2 → 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 (154) hide show
  1. package/dist/src/Assert.d.ts +6 -3
  2. package/dist/src/Assert.d.ts.map +1 -1
  3. package/dist/src/Assert.js +7 -4
  4. package/dist/src/Brand.d.ts +75 -0
  5. package/dist/src/Brand.d.ts.map +1 -0
  6. package/dist/src/Brand.js +1 -0
  7. package/dist/src/Buffer.d.ts +1 -1
  8. package/dist/src/Buffer.d.ts.map +1 -1
  9. package/dist/src/Buffer.js +1 -1
  10. package/dist/src/CallbackRegistry.d.ts +53 -0
  11. package/dist/src/CallbackRegistry.d.ts.map +1 -0
  12. package/dist/src/CallbackRegistry.js +25 -0
  13. package/dist/src/Console.d.ts +31 -6
  14. package/dist/src/Console.d.ts.map +1 -1
  15. package/dist/src/Console.js +72 -9
  16. package/dist/src/Crypto.d.ts +61 -34
  17. package/dist/src/Crypto.d.ts.map +1 -1
  18. package/dist/src/Crypto.js +29 -42
  19. package/dist/src/Evolu/Db.d.ts +137 -66
  20. package/dist/src/Evolu/Db.d.ts.map +1 -1
  21. package/dist/src/Evolu/Db.js +300 -705
  22. package/dist/src/Evolu/Diff.d.ts +3 -3
  23. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  24. package/dist/src/Evolu/Diff.js +7 -5
  25. package/dist/src/Evolu/Evolu.d.ts +200 -115
  26. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  27. package/dist/src/Evolu/Evolu.js +287 -157
  28. package/dist/src/Evolu/Internal.d.ts +0 -2
  29. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  30. package/dist/src/Evolu/Internal.js +0 -2
  31. package/dist/src/Evolu/LocalAuth.d.ts +144 -0
  32. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  33. package/dist/src/Evolu/LocalAuth.js +171 -0
  34. package/dist/src/Evolu/Owner.d.ts +152 -127
  35. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  36. package/dist/src/Evolu/Owner.js +88 -107
  37. package/dist/src/Evolu/Platform.d.ts +9 -7
  38. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  39. package/dist/src/Evolu/Protocol.d.ts +170 -186
  40. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  41. package/dist/src/Evolu/Protocol.js +487 -343
  42. package/dist/src/Evolu/Public.d.ts +6 -8
  43. package/dist/src/Evolu/Public.d.ts.map +1 -1
  44. package/dist/src/Evolu/Public.js +2 -3
  45. package/dist/src/Evolu/PublicKysely.js +3 -3
  46. package/dist/src/Evolu/Query.d.ts +2 -1
  47. package/dist/src/Evolu/Query.d.ts.map +1 -1
  48. package/dist/src/Evolu/Relay.d.ts +4 -3
  49. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  50. package/dist/src/Evolu/Relay.js +50 -12
  51. package/dist/src/Evolu/Schema.d.ts +129 -73
  52. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  53. package/dist/src/Evolu/Schema.js +169 -89
  54. package/dist/src/Evolu/Storage.d.ts +159 -14
  55. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  56. package/dist/src/Evolu/Storage.js +42 -32
  57. package/dist/src/Evolu/Sync.d.ts +77 -13
  58. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  59. package/dist/src/Evolu/Sync.js +453 -20
  60. package/dist/src/Evolu/Timestamp.d.ts +35 -27
  61. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  62. package/dist/src/Evolu/Timestamp.js +27 -18
  63. package/dist/src/ManyToManyMap.d.ts +74 -10
  64. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  65. package/dist/src/ManyToManyMap.js +41 -6
  66. package/dist/src/Number.d.ts +2 -1
  67. package/dist/src/Number.d.ts.map +1 -1
  68. package/dist/src/Random.d.ts +3 -2
  69. package/dist/src/Random.d.ts.map +1 -1
  70. package/dist/src/RefCountedResourceManager.d.ts +119 -0
  71. package/dist/src/RefCountedResourceManager.d.ts.map +1 -0
  72. package/dist/src/RefCountedResourceManager.js +197 -0
  73. package/dist/src/Result.d.ts +147 -51
  74. package/dist/src/Result.d.ts.map +1 -1
  75. package/dist/src/Result.js +7 -241
  76. package/dist/src/Sqlite.d.ts +21 -4
  77. package/dist/src/Sqlite.d.ts.map +1 -1
  78. package/dist/src/Sqlite.js +50 -8
  79. package/dist/src/Task.d.ts +511 -0
  80. package/dist/src/Task.d.ts.map +1 -0
  81. package/dist/src/Task.js +410 -0
  82. package/dist/src/Time.d.ts +59 -0
  83. package/dist/src/Time.d.ts.map +1 -1
  84. package/dist/src/Time.js +87 -4
  85. package/dist/src/Type.d.ts +462 -327
  86. package/dist/src/Type.d.ts.map +1 -1
  87. package/dist/src/Type.js +490 -436
  88. package/dist/src/Types.d.ts +0 -74
  89. package/dist/src/Types.d.ts.map +1 -1
  90. package/dist/src/WebSocket.d.ts +5 -2
  91. package/dist/src/WebSocket.d.ts.map +1 -1
  92. package/dist/src/WebSocket.js +12 -13
  93. package/dist/src/Worker.d.ts +39 -11
  94. package/dist/src/Worker.d.ts.map +1 -1
  95. package/dist/src/Worker.js +22 -4
  96. package/dist/src/index.d.ts +3 -3
  97. package/dist/src/index.d.ts.map +1 -1
  98. package/dist/src/index.js +3 -3
  99. package/package.json +14 -13
  100. package/src/Assert.ts +8 -7
  101. package/src/Brand.ts +75 -0
  102. package/src/Buffer.ts +1 -1
  103. package/src/CallbackRegistry.ts +84 -0
  104. package/src/Console.ts +91 -11
  105. package/src/Crypto.ts +94 -79
  106. package/src/Evolu/Db.ts +505 -1027
  107. package/src/Evolu/Diff.ts +7 -5
  108. package/src/Evolu/Evolu.ts +682 -329
  109. package/src/Evolu/Internal.ts +0 -2
  110. package/src/Evolu/LocalAuth.ts +422 -0
  111. package/src/Evolu/Owner.ts +221 -235
  112. package/src/Evolu/Platform.ts +9 -9
  113. package/src/Evolu/Protocol.ts +681 -614
  114. package/src/Evolu/Public.ts +7 -14
  115. package/src/Evolu/PublicKysely.ts +3 -3
  116. package/src/Evolu/Query.ts +2 -1
  117. package/src/Evolu/Relay.ts +62 -16
  118. package/src/Evolu/Schema.ts +391 -191
  119. package/src/Evolu/Storage.ts +275 -55
  120. package/src/Evolu/Sync.ts +758 -37
  121. package/src/Evolu/Timestamp.ts +38 -35
  122. package/src/ManyToManyMap.ts +127 -24
  123. package/src/Number.ts +2 -6
  124. package/src/Random.ts +3 -2
  125. package/src/RefCountedResourceManager.ts +368 -0
  126. package/src/Result.ts +152 -53
  127. package/src/Sqlite.ts +59 -23
  128. package/src/Task.ts +779 -0
  129. package/src/Time.ts +168 -4
  130. package/src/Type.ts +696 -660
  131. package/src/Types.ts +0 -76
  132. package/src/WebSocket.ts +23 -17
  133. package/src/Worker.ts +72 -23
  134. package/src/index.ts +3 -3
  135. package/dist/src/Callbacks.d.ts +0 -20
  136. package/dist/src/Callbacks.d.ts.map +0 -1
  137. package/dist/src/Callbacks.js +0 -18
  138. package/dist/src/Evolu/Config.d.ts +0 -69
  139. package/dist/src/Evolu/Config.d.ts.map +0 -1
  140. package/dist/src/Evolu/Config.js +0 -9
  141. package/dist/src/Evolu/Kysely.d.ts +0 -6
  142. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  143. package/dist/src/Evolu/Kysely.js +0 -21
  144. package/dist/src/NanoId.d.ts +0 -27
  145. package/dist/src/NanoId.d.ts.map +0 -1
  146. package/dist/src/NanoId.js +0 -6
  147. package/dist/src/Promise.d.ts +0 -180
  148. package/dist/src/Promise.d.ts.map +0 -1
  149. package/dist/src/Promise.js +0 -176
  150. package/src/Callbacks.ts +0 -43
  151. package/src/Evolu/Config.ts +0 -83
  152. package/src/Evolu/Kysely.ts +0 -38
  153. package/src/NanoId.ts +0 -39
  154. package/src/Promise.ts +0 -295
@@ -1,35 +1,57 @@
1
+ /* eslint-disable jsdoc/no-undefined-types */
1
2
  /**
2
3
  * Evolu Protocol
3
4
  *
4
5
  * Evolu Protocol is a local-first, end-to-end encrypted binary synchronization
5
6
  * protocol optimized for minimal size and maximum speed. It enables data sync
6
- * between a client and a relay, clients in a peer-to-peer (P2P) setup, or
7
- * 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).
8
15
  *
9
16
  * Evolu Protocol is designed for SQLite but can be extended to any database. It
10
17
  * implements [Range-Based Set
11
18
  * Reconciliation](https://arxiv.org/abs/2212.13567). To learn how RBSR works,
12
19
  * check [Negentropy](https://logperiodic.com/rbsr.html). Evolu Protocol is
13
20
  * similar to Negentropy but uses different encoding and also provides data
14
- * transfer and ownership.
21
+ * transfer, ownership, real-time broadcasting, request-response semantics, and
22
+ * error handling.
15
23
  *
16
24
  * ### Message Structure
17
25
  *
18
- * | Field | Notes |
19
- * | :----------------------------- | :------------------------- |
20
- * | **Header** | |
21
- * | - {@link protocolVersion} | |
22
- * | - {@link OwnerId} | |
23
- * | - {@link ProtocolErrorCode} | In non-initiator response. |
24
- * | **Messages** | |
25
- * | - {@link NonNegativeInt} | A number of messages. |
26
- * | - {@link EncryptedCrdtMessage} | |
27
- * | - {@link WriteKey} | In initiator request. |
28
- * | **Ranges** | |
29
- * | - {@link NonNegativeInt} | Number of ranges. |
30
- * | - {@link Range} | |
26
+ * | Field | Notes |
27
+ * | :----------------------------- | :------------------------ |
28
+ * | **Header** | |
29
+ * | - {@link protocolVersion} | |
30
+ * | - {@link OwnerId} | {@link Owner} |
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)** | |
37
+ * | - {@link ProtocolErrorCode} | |
38
+ * | **Broadcast (messageType=2)** | |
39
+ * | - (no additional fields) | |
40
+ * | **Messages** | |
41
+ * | - {@link NonNegativeInt} | A number of messages. |
42
+ * | - {@link EncryptedCrdtMessage} | |
43
+ * | **Ranges** | |
44
+ * | - {@link NonNegativeInt} | Number of ranges. |
45
+ * | - {@link Range} | |
46
+ *
47
+ * ### WriteKey Validation
31
48
  *
32
- * Every protocol message belongs to an {@link Owner}.
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.
33
55
  *
34
56
  * ### Synchronization
35
57
  *
@@ -43,16 +65,20 @@
43
65
  * if further sync is needed or possible, continuing until both sides are
44
66
  * synchronized.
45
67
  *
68
+ * The **non-initiator always responds** to provide sync completion feedback,
69
+ * even with empty messages containing only the header and no error. This allows
70
+ * the initiator to detect when synchronization is complete.
71
+ *
46
72
  * Both **Messages** and **Ranges** are optional, allowing each side to send,
47
73
  * sync, or only subscribe data as needed.
48
74
  *
49
- * When the initiator sends data, the {@link WriteKey} is required in Messages as
50
- * a secure token proving the initiator can write changes. The non-initiator
51
- * responds without a {@link WriteKey}, since the initiator’s request already
52
- * signals it wants data. If the non-initiator detects an issue, it sends an
53
- * error code via the `Error` field in the header back to the initiator. In
54
- * relay-to-relay or P2P sync, both sides may require the {@link WriteKey}
55
- * 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.
56
82
  *
57
83
  * ### Protocol Errors
58
84
  *
@@ -66,15 +92,21 @@
66
92
  * - {@link ProtocolUnsupportedVersionError}: Protocol version mismatch.
67
93
  * - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
68
94
  *
69
- * All protocol errors except `ProtocolInvalidDataError` include the `ownerId`
95
+ * All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
70
96
  * to allow clients to associate errors with the correct owner.
71
97
  *
72
98
  * ### Message Size Limit
73
99
  *
74
100
  * The protocol enforces a strict maximum size for all messages, defined by
75
101
  * {@link maxProtocolMessageSize}. This ensures every {@link ProtocolMessage} is
76
- * less than or equal to this limit, eliminating the need for applications to
77
- * 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}.
78
110
  *
79
111
  * ### Why Binary?
80
112
  *
@@ -82,13 +114,12 @@
82
114
  *
83
115
  * - Encrypted data doesn’t compress well, unlike plain JSON.
84
116
  * - Message size must be controlled during creation.
85
- * - Sequential byte reading is faster than parsing and can avoid conversions.
117
+ * - Sequential byte reading is faster than parsing and avoids conversions.
86
118
  *
87
119
  * It uses structure-aware encoding, significantly outperforming generic binary
88
120
  * serialization formats with the following optimizations:
89
121
  *
90
122
  * - **NonNegativeInt:** Up to 33% smaller than MessagePack.
91
- * - **Base64Url Strings:** Up to 25% size reduction.
92
123
  * - **DateIso:** Up to 75% smaller.
93
124
  * - **Timestamp Encoding:** Delta encoding for milliseconds and run-length
94
125
  * encoding (RLE) for counters and NodeIds.
@@ -121,63 +152,92 @@
121
152
  * @module
122
153
  */
123
154
 
124
- import { sha256 } from "@noble/hashes/sha2";
125
- import { pack, unpack, unpackMultiple } from "msgpackr";
155
+ import { Packr } from "msgpackr";
126
156
  import { isNonEmptyReadonlyArray, NonEmptyReadonlyArray } from "../Array.js";
127
157
  import { assert } from "../Assert.js";
158
+ import { Brand } from "../Brand.js";
128
159
  import {
129
160
  Buffer,
130
- BufferError,
131
161
  bytesToHex,
132
162
  bytesToUtf8,
133
- concatBytes,
134
163
  createBuffer,
135
164
  hexToBytes,
136
165
  utf8ToBytes,
137
166
  } from "../Buffer.js";
138
167
  import {
139
- CreateRandomBytesDep,
140
168
  EncryptionKey,
141
169
  padmePaddingLength,
170
+ RandomBytesDep,
142
171
  SymmetricCryptoDecryptError,
143
172
  SymmetricCryptoDep,
144
173
  } from "../Crypto.js";
145
174
  import { eqArrayNumber } from "../Eq.js";
146
175
  import { computeBalancedBuckets } from "../Number.js";
147
- import { objectToEntries, ReadonlyRecord } from "../Object.js";
176
+ import { objectToEntries } from "../Object.js";
148
177
  import { err, ok, Result } from "../Result.js";
149
178
  import { SqliteValue } from "../Sqlite.js";
150
179
  import {
151
180
  Base64Url,
152
- DateIsoString,
181
+ base64UrlToUint8Array,
182
+ DateIso,
153
183
  Id,
154
- idTypeValueLength,
155
- JsonValueFromString,
156
- maxLength,
157
- NanoId,
184
+ IdBytes,
185
+ idBytesToId,
186
+ idBytesTypeValueLength,
187
+ idToIdBytes,
188
+ Json,
189
+ jsonToJsonValue,
158
190
  NonNegativeInt,
159
191
  Number,
160
192
  PositiveInt,
193
+ uint8ArrayToBase64Url,
161
194
  } from "../Type.js";
162
- import { Brand, Predicate } from "../Types.js";
195
+ import { Predicate } from "../Types.js";
163
196
  import {
164
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
165
197
  Owner,
166
198
  OwnerId,
167
- OwnerWithWriteAccess,
168
- WriteKey,
169
- writeKeyLength,
199
+ OwnerIdBytes,
200
+ ownerIdToOwnerIdBytes,
201
+ OwnerWriteKey,
202
+ ownerWriteKeyLength,
170
203
  } from "./Owner.js";
171
204
  import {
172
- BinaryTimestamp,
173
- binaryTimestampToTimestamp,
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 {
174
222
  Counter,
223
+ eqTimestamp,
175
224
  Millis,
176
225
  NodeId,
177
226
  Timestamp,
178
- timestampToBinaryTimestamp,
227
+ TimestampBytes,
228
+ timestampBytesLength,
229
+ timestampBytesToTimestamp,
230
+ timestampToTimestampBytes,
179
231
  } from "./Timestamp.js";
180
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
+
181
241
  /** Maximum size of the entire protocol message in bytes. */
182
242
  export const maxProtocolMessageSize = 1_000_000 as PositiveInt;
183
243
 
@@ -190,6 +250,29 @@ export type ProtocolMessage = Uint8Array & Brand<"ProtocolMessage">;
190
250
  /** Evolu Protocol version. */
191
251
  export const protocolVersion = 0 as NonNegativeInt;
192
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
+
193
276
  export const ProtocolErrorCode = {
194
277
  NoError: 0,
195
278
  /** A code for {@link ProtocolWriteKeyError}. */
@@ -203,168 +286,13 @@ export const ProtocolErrorCode = {
203
286
  type ProtocolErrorCode =
204
287
  (typeof ProtocolErrorCode)[keyof typeof ProtocolErrorCode];
205
288
 
206
- /**
207
- * Evolu Protocol Storage
208
- *
209
- * The protocol is agnostic to storage implementation details—any storage can be
210
- * plugged in, as long as it implements this interface. Implementations must
211
- * handle their own errors; return values only indicates overall success or
212
- * failure.
213
- */
214
- export interface Storage {
215
- readonly getSize: (ownerId: BinaryOwnerId) => NonNegativeInt | null;
216
-
217
- readonly fingerprint: (
218
- ownerId: BinaryOwnerId,
219
- begin: NonNegativeInt,
220
- end: NonNegativeInt,
221
- ) => Fingerprint | null;
222
-
223
- /**
224
- * Computes fingerprints with their upper bounds in one call.
225
- *
226
- * This function can be replaced with many fingerprint/findLowerBound calls,
227
- * but implementations can leverage it for batching and more efficient
228
- * fingerprint computation.
229
- */
230
- readonly fingerprintRanges: (
231
- ownerId: BinaryOwnerId,
232
- buckets: ReadonlyArray<NonNegativeInt>,
233
- upperBound?: RangeUpperBound,
234
- ) => ReadonlyArray<FingerprintRange> | null;
235
-
236
- readonly findLowerBound: (
237
- ownerId: BinaryOwnerId,
238
- begin: NonNegativeInt,
239
- end: NonNegativeInt,
240
- upperBound: RangeUpperBound,
241
- ) => NonNegativeInt | null;
242
-
243
- readonly iterate: (
244
- ownerId: BinaryOwnerId,
245
- begin: NonNegativeInt,
246
- end: NonNegativeInt,
247
- callback: (timestamp: BinaryTimestamp, index: NonNegativeInt) => boolean,
248
- ) => void;
249
-
250
- /**
251
- * Authorizes the initiator's {@link WriteKey} for the given
252
- * {@link BinaryOwnerId}.
253
- *
254
- * For a client that does not expect foreign writes, return `false`.
255
- */
256
- readonly validateWriteKey: (
257
- ownerId: BinaryOwnerId,
258
- writeKey: WriteKey,
259
- ) => boolean;
260
-
261
- /** Write encrypted {@link CrdtMessage}s to storage. */
262
- readonly writeMessages: (
263
- ownerId: BinaryOwnerId,
264
- messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
265
- ) => boolean;
266
-
267
- /** Read encrypted {@link DbChange}s from storage. */
268
- readonly readDbChange: (
269
- ownerId: BinaryOwnerId,
270
- timestamp: BinaryTimestamp,
271
- ) => EncryptedDbChange | null;
272
- }
273
-
274
- export interface StorageDep {
275
- readonly storage: Storage;
276
- }
277
-
278
- /** An encrypted {@link CrdtMessage}. */
279
- export interface EncryptedCrdtMessage {
280
- readonly timestamp: Timestamp;
281
- readonly change: EncryptedDbChange;
282
- }
283
-
284
- /** Encrypted DbChange */
285
- export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
286
-
287
- /**
288
- * A CRDT message that combines a unique {@link Timestamp} with a
289
- * {@link DbChange}.
290
- */
291
- export interface CrdtMessage {
292
- readonly timestamp: Timestamp;
293
- readonly change: DbChange;
294
- }
295
-
296
- /**
297
- * A DbChange is a change to a table row. Together with a unique
298
- * {@link Timestamp}, it forms a {@link CrdtMessage}.
299
- */
300
- export interface DbChange {
301
- readonly table: Base64Url256;
302
- readonly id: Id;
303
- readonly values: ReadonlyRecord<Base64Url256, SqliteValue>;
304
- }
305
-
306
- export const RangeType = {
307
- Fingerprint: 1,
308
- Skip: 0,
309
- Timestamps: 2,
310
- } as const;
311
-
312
- export type RangeType = (typeof RangeType)[keyof typeof RangeType];
313
-
314
- export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
315
- export type InfiniteUpperBound = typeof InfiniteUpperBound;
316
-
317
- /**
318
- * Union type for Range's upperBound: either a {@link BinaryTimestamp} or
319
- * {@link InfiniteUpperBound}.
320
- */
321
- export type RangeUpperBound = BinaryTimestamp | InfiniteUpperBound;
322
-
323
- interface BaseRange {
324
- readonly upperBound: RangeUpperBound;
325
- }
326
-
327
- export interface SkipRange extends BaseRange {
328
- readonly type: typeof RangeType.Skip;
329
- }
330
-
331
- export interface FingerprintRange extends BaseRange {
332
- readonly type: typeof RangeType.Fingerprint;
333
- readonly fingerprint: Fingerprint;
334
- }
335
-
336
- /**
337
- * A cryptographic hash used for efficiently comparing collections of
338
- * {@link BinaryTimestamp}s.
339
- *
340
- * It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
341
- * one or more timestamps.
342
- */
343
- export type Fingerprint = Uint8Array & Brand<"Fingerprint">;
344
-
345
- export const fingerprintSize = 12 as NonNegativeInt;
346
-
347
- /** A fingerprint of an empty range. */
348
- export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint;
349
-
350
- export interface TimestampsRange extends BaseRange {
351
- readonly type: typeof RangeType.Timestamps;
352
- readonly timestamps: ReadonlyArray<BinaryTimestamp>;
353
- }
354
-
355
- export interface TimestampsRangeWithTimestampsBuffer extends BaseRange {
356
- readonly type: typeof RangeType.Timestamps;
357
- readonly timestamps: TimestampsBuffer;
358
- }
359
-
360
- export type Range = SkipRange | FingerprintRange | TimestampsRange;
361
-
362
289
  export type ProtocolError =
363
290
  | ProtocolUnsupportedVersionError
364
291
  | ProtocolInvalidDataError
365
292
  | ProtocolWriteKeyError
366
293
  | ProtocolWriteError
367
- | ProtocolSyncError;
294
+ | ProtocolSyncError
295
+ | ProtocolTimestampMismatchError;
368
296
 
369
297
  /** Base interface for all protocol errors. */
370
298
  export interface ProtocolErrorBase {
@@ -389,7 +317,7 @@ export interface ProtocolInvalidDataError {
389
317
  readonly error: unknown;
390
318
  }
391
319
 
392
- /** Error when a {@link WriteKey} is invalid, missing, or fails validation. */
320
+ /** Error when a {@link OwnerWriteKey} is invalid, missing, or fails validation. */
393
321
  export interface ProtocolWriteKeyError extends ProtocolErrorBase {
394
322
  readonly type: "ProtocolWriteKeyError";
395
323
  }
@@ -410,6 +338,17 @@ export interface ProtocolSyncError extends ProtocolErrorBase {
410
338
  readonly type: "ProtocolSyncError";
411
339
  }
412
340
 
341
+ /**
342
+ * Error when embedded timestamp doesn't match expected timestamp in
343
+ * EncryptedDbChange. Indicates potential tampering or corruption of CRDT
344
+ * messages.
345
+ */
346
+ export interface ProtocolTimestampMismatchError {
347
+ readonly type: "ProtocolTimestampMismatchError";
348
+ readonly expected: Timestamp;
349
+ readonly embedded: Timestamp;
350
+ }
351
+
413
352
  /**
414
353
  * Creates a {@link ProtocolMessage} from CRDT messages.
415
354
  *
@@ -418,13 +357,14 @@ export interface ProtocolSyncError extends ProtocolErrorBase {
418
357
  * unidirectional and stateless transports.
419
358
  */
420
359
  export const createProtocolMessageFromCrdtMessages =
421
- (deps: SymmetricCryptoDep & CreateRandomBytesDep) =>
360
+ (deps: RandomBytesDep & SymmetricCryptoDep) =>
422
361
  (
423
- owner: OwnerWithWriteAccess,
362
+ owner: Owner,
424
363
  messages: NonEmptyReadonlyArray<CrdtMessage>,
425
364
  maxSize?: PositiveInt,
426
365
  ): ProtocolMessage => {
427
366
  const buffer = createProtocolMessageBuffer(owner.id, {
367
+ messageType: MessageType.Request,
428
368
  totalMaxSize: maxSize ?? maxProtocolMessageSize,
429
369
  writeKey: owner.writeKey,
430
370
  });
@@ -433,7 +373,7 @@ export const createProtocolMessageFromCrdtMessages =
433
373
 
434
374
  for (const message of messages) {
435
375
  const change = encodeAndEncryptDbChange(deps)(
436
- message.change,
376
+ message,
437
377
  owner.encryptionKey,
438
378
  );
439
379
  const encryptedCrdtMessage = { timestamp: message.timestamp, change };
@@ -461,7 +401,7 @@ export const createProtocolMessageFromCrdtMessages =
461
401
  * For now, using a random fingerprint avoids extra complexity and is good
462
402
  * enough for this case.
463
403
  */
464
- const randomFingerprint = deps.createRandomBytes(
404
+ const randomFingerprint = deps.randomBytes.create(
465
405
  fingerprintSize,
466
406
  ) as unknown as Fingerprint;
467
407
 
@@ -479,16 +419,22 @@ export const createProtocolMessageFromCrdtMessages =
479
419
  /** Creates a {@link ProtocolMessage} for sync. */
480
420
  export const createProtocolMessageForSync =
481
421
  (deps: StorageDep) =>
482
- (ownerId: OwnerId): ProtocolMessage | null => {
483
- const buffer = createProtocolMessageBuffer(ownerId);
484
- 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);
485
431
 
486
- const size = deps.storage.getSize(binaryOwnerId);
432
+ const size = deps.storage.getSize(ownerIdBytes);
487
433
  // Errors are handled by the storage.
488
434
  if (size == null) return null;
489
435
 
490
436
  splitRange(deps)(
491
- binaryOwnerId,
437
+ ownerIdBytes,
492
438
  0 as NonNegativeInt,
493
439
  size,
494
440
  InfiniteUpperBound,
@@ -498,6 +444,14 @@ export const createProtocolMessageForSync =
498
444
  return buffer.unwrap();
499
445
  };
500
446
 
447
+ export const createProtocolMessageForUnsubscribe = (
448
+ ownerId: OwnerId,
449
+ ): ProtocolMessage =>
450
+ createProtocolMessageBuffer(ownerId, {
451
+ messageType: MessageType.Request,
452
+ subscriptionFlag: SubscriptionFlags.Unsubscribe,
453
+ }).unwrap();
454
+
501
455
  /**
502
456
  * Mutable builder for constructing {@link ProtocolMessage} respecting size
503
457
  * limits.
@@ -525,16 +479,25 @@ export interface ProtocolMessageBuffer {
525
479
  export const createProtocolMessageBuffer = (
526
480
  ownerId: OwnerId,
527
481
  options: {
528
- readonly errorCode?: ProtocolErrorCode;
529
- readonly writeKey?: WriteKey;
530
482
  readonly totalMaxSize?: PositiveInt | undefined;
531
483
  readonly rangesMaxSize?: PositiveInt | undefined;
532
484
  readonly version?: NonNegativeInt;
533
- } = {},
485
+ } & (
486
+ | {
487
+ readonly messageType: typeof MessageType.Request;
488
+ readonly writeKey?: OwnerWriteKey;
489
+ readonly subscriptionFlag?: SubscriptionFlag;
490
+ }
491
+ | {
492
+ readonly messageType: typeof MessageType.Response;
493
+ readonly errorCode: ProtocolErrorCode;
494
+ }
495
+ | {
496
+ readonly messageType: typeof MessageType.Broadcast;
497
+ }
498
+ ),
534
499
  ): ProtocolMessageBuffer => {
535
500
  const {
536
- errorCode,
537
- writeKey,
538
501
  totalMaxSize = maxProtocolMessageSize,
539
502
  rangesMaxSize = maxProtocolMessageRangesSize,
540
503
  version = protocolVersion,
@@ -554,8 +517,21 @@ export const createProtocolMessageBuffer = (
554
517
  };
555
518
 
556
519
  encodeNonNegativeInt(buffers.header, version);
557
- buffers.header.extend(ownerIdToBinaryOwnerId(ownerId));
558
- if (errorCode != null) buffers.header.extend([errorCode]);
520
+ buffers.header.extend(ownerIdToOwnerIdBytes(ownerId));
521
+ buffers.header.extend([options.messageType]);
522
+
523
+ if (options.messageType === MessageType.Request) {
524
+ if (!options.writeKey) {
525
+ buffers.header.extend([0]);
526
+ } else {
527
+ buffers.header.extend([1]);
528
+ buffers.header.extend(options.writeKey);
529
+ }
530
+ const subscriptionFlag = options.subscriptionFlag ?? SubscriptionFlags.None;
531
+ buffers.header.extend([subscriptionFlag]);
532
+ } else if (options.messageType === MessageType.Response) {
533
+ buffers.header.extend([options.errorCode]);
534
+ }
559
535
 
560
536
  let isLastRangeInfinite = false;
561
537
 
@@ -567,10 +543,7 @@ export const createProtocolMessageBuffer = (
567
543
  const getHeaderAndMessagesSize = () =>
568
544
  buffers.header.getLength() +
569
545
  buffers.messages.timestamps.getLength() +
570
- buffers.messages.dbChanges.getLength() +
571
- (buffers.messages.timestamps.getCount() > 0 && writeKey
572
- ? writeKeyLength
573
- : 0);
546
+ buffers.messages.dbChanges.getLength();
574
547
 
575
548
  const getRangesSize = () =>
576
549
  buffers.ranges.timestamps.getCount() > 0
@@ -639,10 +612,15 @@ export const createProtocolMessageBuffer = (
639
612
  },
640
613
 
641
614
  addRange: (range) => {
615
+ assert(
616
+ options.messageType !== MessageType.Broadcast,
617
+ "Cannot add a range into broadcast message",
618
+ );
642
619
  assert(
643
620
  !isLastRangeInfinite,
644
621
  "Cannot add a range after an InfiniteUpperBound range",
645
622
  );
623
+
646
624
  isLastRangeInfinite = range.upperBound === InfiniteUpperBound;
647
625
 
648
626
  /**
@@ -652,7 +630,7 @@ export const createProtocolMessageBuffer = (
652
630
  */
653
631
  if (range.upperBound !== InfiniteUpperBound)
654
632
  buffers.ranges.timestamps.add(
655
- binaryTimestampToTimestamp(range.upperBound),
633
+ timestampBytesToTimestamp(range.upperBound),
656
634
  );
657
635
  else {
658
636
  buffers.ranges.timestamps.addInfinite();
@@ -685,8 +663,6 @@ export const createProtocolMessageBuffer = (
685
663
 
686
664
  buffers.messages.timestamps.append(buffers.header);
687
665
  buffers.header.extend(buffers.messages.dbChanges.unwrap());
688
- if (buffers.messages.timestamps.getCount() > 0 && writeKey)
689
- buffers.header.extend(writeKey);
690
666
 
691
667
  if (buffers.ranges.timestamps.getCount() > 0) {
692
668
  buffers.ranges.timestamps.append(buffers.header);
@@ -701,6 +677,11 @@ export const createProtocolMessageBuffer = (
701
677
  };
702
678
  };
703
679
 
680
+ export interface TimestampsRangeWithTimestampsBuffer extends BaseRange {
681
+ readonly type: typeof RangeType.Timestamps;
682
+ readonly timestamps: TimestampsBuffer;
683
+ }
684
+
704
685
  export interface TimestampsBuffer {
705
686
  readonly add: (timestamp: Timestamp) => void;
706
687
  readonly addInfinite: () => void;
@@ -802,7 +783,7 @@ const createRunLengthEncoder = <T>(
802
783
  };
803
784
 
804
785
  export interface ApplyProtocolMessageAsClientOptions {
805
- getWriteKey?: (ownerId: OwnerId) => WriteKey | null;
786
+ getWriteKey?: (ownerId: OwnerId) => OwnerWriteKey | null;
806
787
 
807
788
  /** For testing purposes only; should not be used in production. */
808
789
  version?: NonNegativeInt;
@@ -811,33 +792,53 @@ export interface ApplyProtocolMessageAsClientOptions {
811
792
  rangesMaxSize?: PositiveInt;
812
793
  }
813
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
+
814
804
  export const applyProtocolMessageAsClient =
815
805
  (deps: StorageDep) =>
816
- (
806
+ async (
817
807
  inputMessage: Uint8Array,
818
- {
819
- getWriteKey,
820
- version = protocolVersion,
821
- totalMaxSize,
822
- rangesMaxSize,
823
- }: ApplyProtocolMessageAsClientOptions = {},
824
- ): Result<ProtocolMessage | null, ProtocolError> =>
825
- tryDecodeProtocolData<ProtocolMessage | null, ProtocolError>(
826
- inputMessage,
827
- (input) => {
828
- const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
829
-
830
- if (requestedVersion !== version) {
831
- return err<ProtocolUnsupportedVersionError>({
832
- type: "ProtocolUnsupportedVersionError",
833
- unsupportedVersion: requestedVersion,
834
- isInitiator: version < requestedVersion,
835
- ownerId,
836
- });
837
- }
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;
838
824
 
839
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
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
840
 
841
+ if (messageType === MessageType.Response) {
841
842
  const errorCode = input.shift() as ProtocolErrorCode;
842
843
  if (errorCode !== ProtocolErrorCode.NoError) {
843
844
  switch (errorCode) {
@@ -862,34 +863,69 @@ export const applyProtocolMessageAsClient =
862
863
  );
863
864
  }
864
865
  }
866
+ }
865
867
 
866
- const messages = decodeMessages(input);
868
+ const messages = decodeMessages(input);
869
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
867
870
 
868
- if (
869
- isNonEmptyReadonlyArray(messages) &&
870
- !deps.storage.writeMessages(binaryOwnerId, messages)
871
- ) {
872
- return ok(null);
873
- }
871
+ if (
872
+ isNonEmptyReadonlyArray(messages) &&
873
+ !(await deps.storage.writeMessages(ownerIdBytes, messages))
874
+ ) {
875
+ return ok({ type: "no-response" });
876
+ }
874
877
 
875
- if (!getWriteKey) return ok(null);
876
- const writeKey = getWriteKey(ownerId);
877
- 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
+ }
878
887
 
879
- const output = createProtocolMessageBuffer(ownerId, {
880
- writeKey,
881
- totalMaxSize,
882
- rangesMaxSize,
883
- });
888
+ if (messageType === MessageType.Broadcast) {
889
+ return ok({ type: "broadcast" });
890
+ }
884
891
 
885
- return sync(deps)("initiator", input, output, binaryOwnerId);
886
- },
887
- );
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
+ };
888
921
 
889
922
  export interface ApplyProtocolMessageAsRelayOptions {
890
923
  /** To subscribe an owner for broadcasting. */
891
924
  subscribe?: (ownerId: OwnerId) => void;
892
925
 
926
+ /** To unsubscribe an owner from broadcasting. */
927
+ unsubscribe?: (ownerId: OwnerId) => void;
928
+
893
929
  /** To broadcast a protocol message to all subscribers. */
894
930
  broadcast?: (ownerId: OwnerId, message: ProtocolMessage) => void;
895
931
 
@@ -897,106 +933,174 @@ export interface ApplyProtocolMessageAsRelayOptions {
897
933
  rangesMaxSize?: PositiveInt;
898
934
  }
899
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
+
900
950
  export const applyProtocolMessageAsRelay =
901
951
  (deps: StorageDep) =>
902
- (
952
+ async (
903
953
  inputMessage: Uint8Array,
904
- {
905
- subscribe,
906
- broadcast,
907
- totalMaxSize,
908
- rangesMaxSize,
909
- }: ApplyProtocolMessageAsRelayOptions = {},
954
+ options: ApplyProtocolMessageAsRelayOptions = {},
910
955
  /** For testing purposes only; should not be used in production. */
911
956
  version = protocolVersion,
912
- ): Result<ProtocolMessage | null, ProtocolInvalidDataError> =>
913
- tryDecodeProtocolData(inputMessage, (input) => {
914
- const requestedVersion = decodeNonNegativeInt(input);
915
- const ownerId = decodeOwnerId(input);
916
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
957
+ ): Promise<
958
+ Result<ApplyProtocolMessageAsRelayResult, ProtocolInvalidDataError>
959
+ > => {
960
+ // try-catch instead of Result for performance and stacktraces
961
+ try {
962
+ const input = createBuffer(inputMessage);
963
+ const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
964
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
917
965
 
918
966
  if (requestedVersion !== version) {
919
967
  // Non-initiator responds with its version and ownerId.
920
968
  const output = createBuffer();
921
969
  encodeNonNegativeInt(output, version);
922
- output.extend(binaryOwnerId);
923
- return ok(output.unwrap() as ProtocolMessage);
970
+ output.extend(ownerIdBytes);
971
+ return ok({
972
+ type: "response",
973
+ message: output.unwrap() as ProtocolMessage,
974
+ });
924
975
  }
925
976
 
926
- subscribe?.(ownerId);
977
+ const messageType = input.shift() as MessageType;
978
+ assert(messageType === MessageType.Request, "Invalid MessageType");
927
979
 
928
- const messages = decodeMessages(input);
980
+ const hasWriteKey = input.shift();
981
+ let writeKey: OwnerWriteKey | undefined;
929
982
 
930
- if (isNonEmptyReadonlyArray(messages)) {
931
- const messagesEnd = inputMessage.length - input.getLength();
932
- const writeKey = input.shiftN(writeKeyLength) as WriteKey;
983
+ if (hasWriteKey === 1) {
984
+ writeKey = input.shiftN(ownerWriteKeyLength) as OwnerWriteKey;
985
+ }
933
986
 
934
- const writeKeyIsValid = deps.storage.validateWriteKey(
935
- binaryOwnerId,
936
- writeKey,
937
- );
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;
998
+ }
938
999
 
939
- if (!writeKeyIsValid)
940
- return ok(
941
- createProtocolMessageBuffer(ownerId, {
1000
+ if (writeKey) {
1001
+ const isValid = deps.storage.validateWriteKey(ownerIdBytes, writeKey);
1002
+ if (!isValid) {
1003
+ return ok({
1004
+ type: "response",
1005
+ message: createProtocolMessageBuffer(ownerId, {
1006
+ messageType: MessageType.Response,
942
1007
  errorCode: ProtocolErrorCode.WriteKeyError,
943
1008
  }).unwrap(),
944
- );
1009
+ });
1010
+ }
1011
+ }
945
1012
 
946
- if (broadcast) {
947
- // Instead of encoding a new protocol message, we reuse the inputMessage.
948
- const broadcastMessage = concatBytes(
949
- inputMessage.slice(0, 17),
950
- new Uint8Array([ProtocolErrorCode.NoError]),
951
- inputMessage.slice(17, messagesEnd),
952
- ) as ProtocolMessage;
1013
+ const messages = decodeMessages(input);
953
1014
 
954
- broadcast(ownerId, broadcastMessage);
1015
+ if (isNonEmptyReadonlyArray(messages)) {
1016
+ if (!writeKey) {
1017
+ return ok({
1018
+ type: "response",
1019
+ message: createProtocolMessageBuffer(ownerId, {
1020
+ messageType: MessageType.Response,
1021
+ errorCode: ProtocolErrorCode.WriteKeyError,
1022
+ }).unwrap(),
1023
+ });
955
1024
  }
956
1025
 
957
- if (!deps.storage.writeMessages(binaryOwnerId, messages))
958
- return ok(
959
- createProtocolMessageBuffer(ownerId, {
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) {
1039
+ const broadcastBuffer = createProtocolMessageBuffer(ownerId, {
1040
+ messageType: MessageType.Broadcast,
1041
+ totalMaxSize: options.totalMaxSize,
1042
+ rangesMaxSize: options.rangesMaxSize,
1043
+ version,
1044
+ });
1045
+ for (const message of messages) {
1046
+ broadcastBuffer.addMessage(message);
1047
+ }
1048
+ options.broadcast(ownerId, broadcastBuffer.unwrap());
1049
+ }
1050
+
1051
+ if (!(await deps.storage.writeMessages(ownerIdBytes, messages))) {
1052
+ return ok({
1053
+ type: "response",
1054
+ message: createProtocolMessageBuffer(ownerId, {
1055
+ messageType: MessageType.Response,
960
1056
  errorCode: ProtocolErrorCode.WriteError,
961
1057
  }).unwrap(),
962
- );
1058
+ });
1059
+ }
963
1060
  }
964
1061
 
1062
+ const ranges = decodeRanges(input);
1063
+
965
1064
  const output = createProtocolMessageBuffer(ownerId, {
1065
+ messageType: MessageType.Response,
966
1066
  errorCode: ProtocolErrorCode.NoError,
967
- totalMaxSize,
968
- rangesMaxSize,
1067
+ totalMaxSize: options.totalMaxSize,
1068
+ rangesMaxSize: options.rangesMaxSize,
969
1069
  });
970
1070
 
971
- return sync(deps)("non-initiator", input, output, binaryOwnerId);
972
- });
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
+ }
973
1076
 
974
- /**
975
- * Wraps Evolu Protocol decoding functions, which use exceptions instead of
976
- * {@link Result} to provide stack traces for debugging and reduce allocation
977
- * overhead in success cases.
978
- */
979
- const tryDecodeProtocolData = <T, E>(
980
- data: Uint8Array,
981
- callback: (buffer: Buffer) => Result<T, E | ProtocolInvalidDataError>,
982
- ) => {
983
- try {
984
- return callback(createBuffer(data));
985
- } catch (error: unknown) {
986
- if (error instanceof ProtocolDecodeError || error instanceof BufferError)
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) {
987
1089
  return err<ProtocolInvalidDataError>({
988
1090
  type: "ProtocolInvalidDataError",
989
- data,
1091
+ data: inputMessage,
990
1092
  error,
991
1093
  });
992
-
993
- throw error;
994
- }
995
- };
1094
+ }
1095
+ };
996
1096
 
997
1097
  const decodeVersionAndOwner = (input: Buffer): [NonNegativeInt, OwnerId] => {
1098
+ // This structure must never change across protocol versions. The version
1099
+ // and owner ID must always be the first two fields in every protocol message
1100
+ // to enable version negotiation and owner identification before any other
1101
+ // processing occurs.
998
1102
  const version = decodeNonNegativeInt(input);
999
- const ownerId = decodeOwnerId(input);
1103
+ const ownerId = decodeId(input) as OwnerId;
1000
1104
  return [version, ownerId];
1001
1105
  };
1002
1106
 
@@ -1031,34 +1135,14 @@ const decodeMessages = (
1031
1135
  const sync =
1032
1136
  (deps: StorageDep) =>
1033
1137
  (
1034
- role: "initiator" | "non-initiator",
1035
- input: Buffer,
1138
+ ranges: NonEmptyReadonlyArray<Range>,
1036
1139
  output: ProtocolMessageBuffer,
1037
- ownerId: BinaryOwnerId,
1038
- ): Result<ProtocolMessage | null, never> => {
1039
- const ranges = decodeRanges(input);
1040
-
1041
- if (!isNonEmptyReadonlyArray(ranges)) {
1042
- // Nothing to sync.
1043
- return ok(null);
1044
- }
1045
-
1046
- const binaryOwnerId = binaryOwnerIdToOwnerId(ownerId);
1140
+ ownerIdBytes: OwnerIdBytes,
1141
+ ): Result<boolean, typeof ProtocolErrorCode.SyncError> => {
1047
1142
  const outputInitialSize = output.getSize();
1048
1143
 
1049
- const syncFail = () => {
1050
- // Only the relay (non-initiator) reports sync errors, not the client (initiator).
1051
- if (role === "initiator") {
1052
- return ok(null);
1053
- }
1054
- const message = createProtocolMessageBuffer(binaryOwnerId, {
1055
- errorCode: ProtocolErrorCode.SyncError,
1056
- });
1057
- return ok(message.unwrap());
1058
- };
1059
-
1060
- const storageSize = deps.storage.getSize(ownerId);
1061
- if (storageSize == null) return syncFail();
1144
+ const storageSize = deps.storage.getSize(ownerIdBytes);
1145
+ if (storageSize == null) return err(ProtocolErrorCode.SyncError);
1062
1146
 
1063
1147
  let prevUpperBound: RangeUpperBound | null = null;
1064
1148
  let prevIndex = 0 as NonNegativeInt;
@@ -1098,7 +1182,11 @@ const sync =
1098
1182
  const addFingerprintForRemainingRange = (
1099
1183
  begin: NonNegativeInt,
1100
1184
  ): boolean => {
1101
- const fingerprint = deps.storage.fingerprint(ownerId, begin, storageSize);
1185
+ const fingerprint = deps.storage.fingerprint(
1186
+ ownerIdBytes,
1187
+ begin,
1188
+ storageSize,
1189
+ );
1102
1190
  if (!fingerprint) return false;
1103
1191
  // There is always a space for a ramaining range.
1104
1192
  output.addRange({
@@ -1114,12 +1202,12 @@ const sync =
1114
1202
 
1115
1203
  const lower = prevIndex;
1116
1204
  let upper = deps.storage.findLowerBound(
1117
- ownerId,
1205
+ ownerIdBytes,
1118
1206
  prevIndex,
1119
1207
  storageSize,
1120
1208
  currentUpperBound,
1121
1209
  );
1122
- if (upper == null) return syncFail();
1210
+ if (upper == null) return err(ProtocolErrorCode.SyncError);
1123
1211
 
1124
1212
  switch (range.type) {
1125
1213
  case RangeType.Skip: {
@@ -1129,11 +1217,11 @@ const sync =
1129
1217
 
1130
1218
  case RangeType.Fingerprint: {
1131
1219
  const ourFingerprint = deps.storage.fingerprint(
1132
- ownerId,
1220
+ ownerIdBytes,
1133
1221
  lower,
1134
1222
  upper,
1135
1223
  );
1136
- if (ourFingerprint == null) return syncFail();
1224
+ if (ourFingerprint == null) return err(ProtocolErrorCode.SyncError);
1137
1225
 
1138
1226
  if (eqArrayNumber(range.fingerprint, ourFingerprint)) {
1139
1227
  skipRange(range);
@@ -1141,15 +1229,16 @@ const sync =
1141
1229
  if (output.canSplitRange()) {
1142
1230
  coalesceSkipsBeforeAdd();
1143
1231
  splitRange(deps)(
1144
- ownerId,
1232
+ ownerIdBytes,
1145
1233
  lower,
1146
1234
  upper,
1147
1235
  currentUpperBound,
1148
1236
  output,
1149
1237
  );
1150
1238
  } else {
1151
- if (!addFingerprintForRemainingRange(upper)) return syncFail();
1152
- return ok(output.unwrap());
1239
+ return addFingerprintForRemainingRange(upper)
1240
+ ? ok(true)
1241
+ : err(ProtocolErrorCode.SyncError);
1153
1242
  }
1154
1243
  }
1155
1244
  break;
@@ -1163,45 +1252,53 @@ const sync =
1163
1252
  );
1164
1253
  const ourTimestamps = createTimestampsBuffer();
1165
1254
 
1166
- let storageError = false as boolean;
1255
+ let cantReadDbChange = false as boolean;
1167
1256
  let exceeded = false as boolean;
1168
1257
 
1169
- deps.storage.iterate(ownerId, lower, upper, (timestamp, index) => {
1170
- const timestampString = timestamp.join();
1171
- const timestampBinary = binaryTimestampToTimestamp(timestamp);
1172
-
1173
- 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
+ }
1174
1284
 
1175
- if (timestampsWeNeed.has(timestampString)) {
1176
- timestampsWeNeed.delete(timestampString);
1177
- } else {
1178
- const dbChange = deps.storage.readDbChange(ownerId, timestamp);
1179
- if (dbChange == null) {
1180
- storageError = true;
1285
+ if (
1286
+ !output.canAddTimestampsRangeAndMessage(ourTimestamps, message)
1287
+ ) {
1288
+ exceeded = true;
1289
+ endBound = timestamp;
1290
+ upper = index;
1181
1291
  return false;
1182
1292
  }
1183
- message = {
1184
- timestamp: timestampBinary,
1185
- change: dbChange,
1186
- };
1187
- }
1188
1293
 
1189
- if (
1190
- !output.canAddTimestampsRangeAndMessage(ourTimestamps, message)
1191
- ) {
1192
- exceeded = true;
1193
- endBound = timestamp;
1194
- upper = index;
1195
- return false;
1196
- }
1197
-
1198
- ourTimestamps.add(timestampBinary);
1199
- if (message) output.addMessage(message);
1200
- return true;
1201
- });
1294
+ ourTimestamps.add(timestampBinary);
1295
+ if (message) output.addMessage(message);
1296
+ return true;
1297
+ },
1298
+ );
1202
1299
 
1203
- if (storageError) {
1204
- return syncFail();
1300
+ if (cantReadDbChange) {
1301
+ return err(ProtocolErrorCode.SyncError);
1205
1302
  }
1206
1303
 
1207
1304
  const addRange = () => {
@@ -1216,9 +1313,9 @@ const sync =
1216
1313
  if (exceeded) {
1217
1314
  addRange();
1218
1315
  if (!addFingerprintForRemainingRange(upper)) {
1219
- return syncFail();
1316
+ return err(ProtocolErrorCode.SyncError);
1220
1317
  }
1221
- return ok(output.unwrap());
1318
+ return ok(true);
1222
1319
  }
1223
1320
 
1224
1321
  // If we need something, we have to respond with our timestamps.
@@ -1238,13 +1335,14 @@ const sync =
1238
1335
 
1239
1336
  // If all ranges were skipped, there are no changes and sync is complete.
1240
1337
  const hasChange = output.getSize() > outputInitialSize;
1241
- return ok(hasChange ? output.unwrap() : null);
1338
+
1339
+ return ok(hasChange);
1242
1340
  };
1243
1341
 
1244
1342
  const splitRange =
1245
1343
  (deps: StorageDep) =>
1246
1344
  (
1247
- ownerId: BinaryOwnerId,
1345
+ ownerId: OwnerIdBytes,
1248
1346
  lower: NonNegativeInt,
1249
1347
  upper: NonNegativeInt,
1250
1348
  upperBound: RangeUpperBound,
@@ -1265,7 +1363,7 @@ const splitRange =
1265
1363
  0 as NonNegativeInt,
1266
1364
  itemCount,
1267
1365
  (timestamp) => {
1268
- range.timestamps.add(binaryTimestampToTimestamp(timestamp));
1366
+ range.timestamps.add(timestampBytesToTimestamp(timestamp));
1269
1367
  return true;
1270
1368
  },
1271
1369
  );
@@ -1324,7 +1422,7 @@ const decodeRanges = (buffer: Buffer): ReadonlyArray<Range> => {
1324
1422
  for (let i = 0; i < rangesCount; i++) {
1325
1423
  const upperBound =
1326
1424
  i < timestampsCount
1327
- ? timestampToBinaryTimestamp(timestamps[i])
1425
+ ? timestampToTimestampBytes(timestamps[i])
1328
1426
  : InfiniteUpperBound;
1329
1427
 
1330
1428
  const rangeType = rangeTypes[i];
@@ -1346,7 +1444,7 @@ const decodeRanges = (buffer: Buffer): ReadonlyArray<Range> => {
1346
1444
 
1347
1445
  case RangeType.Timestamps: {
1348
1446
  const timestamps = decodeTimestamps(buffer).map(
1349
- timestampToBinaryTimestamp,
1447
+ timestampToTimestampBytes,
1350
1448
  );
1351
1449
  ranges.push({
1352
1450
  type: RangeType.Timestamps,
@@ -1374,7 +1472,7 @@ const decodeTimestamps = (
1374
1472
  for (let i = 0; i < length; i++) {
1375
1473
  const deltaMillis = decodeNonNegativeInt(buffer);
1376
1474
  const millis = Millis.from(previousMillis + deltaMillis);
1377
- if (!millis.ok) throw new Error(millis.error.type);
1475
+ if (!millis.ok) throw new ProtocolDecodeError(millis.error.type);
1378
1476
  millises.push(millis.value);
1379
1477
  previousMillis = millis.value;
1380
1478
  }
@@ -1383,7 +1481,7 @@ const decodeTimestamps = (
1383
1481
  let counterIndex = 0;
1384
1482
  while (counterIndex < length) {
1385
1483
  const counter = Counter.from(decodeNonNegativeInt(buffer));
1386
- if (!counter.ok) throw new Error(counter.error.type);
1484
+ if (!counter.ok) throw new ProtocolDecodeError(counter.error.type);
1387
1485
  const runLength = decodeNonNegativeInt(buffer);
1388
1486
  for (let i = 0; i < runLength; i++) {
1389
1487
  counters.push(counter.value);
@@ -1414,134 +1512,31 @@ const decodeTimestamps = (
1414
1512
  return timestamps;
1415
1513
  };
1416
1514
 
1417
- /** Binary representation of {@link Id}. */
1418
- export type BinaryId = Uint8Array & Brand<"BinaryId">;
1419
-
1420
- export const binaryIdLength = 16 as NonNegativeInt;
1421
-
1422
- export const idToBinaryId = (id: Id): BinaryId =>
1423
- base64Url256ToBytes(id) as BinaryId;
1424
-
1425
- export const binaryIdToId = (binaryId: BinaryId): Id =>
1426
- decodeId(createBuffer(binaryId));
1427
-
1428
- /** Binary representation of {@link OwnerId}. */
1429
- export type BinaryOwnerId = Uint8Array & Brand<"BinaryOwnerId">;
1430
-
1431
- export const ownerIdToBinaryOwnerId = (ownerId: OwnerId): BinaryOwnerId =>
1432
- base64Url256ToBytes(ownerId) as BinaryOwnerId;
1433
-
1434
- export const binaryOwnerIdToOwnerId = (binaryOwnerId: BinaryOwnerId): OwnerId =>
1435
- decodeOwnerId(createBuffer(binaryOwnerId));
1436
-
1437
- /**
1438
- * Base64Url string with maximum length of 256 characters. Encoding strings as
1439
- * Base64UrlString saves up to 25% in size compared to regular strings.
1440
- */
1441
- export const Base64Url256 = maxLength(256)(Base64Url);
1442
- export type Base64Url256 = typeof Base64Url256.Type;
1443
-
1444
- /**
1445
- * Union type for all variants of Base64Url strings with limited length. All
1446
- * these types use Base64Url alphabet and are < 256 characters.
1447
- */
1448
- export type Base64Url256Variant = Base64Url256 | Id | NanoId | OwnerId;
1449
-
1450
- /**
1451
- * Alphabet used for Base64Url encoding. This is copied from the `nanoid`
1452
- * library to avoid dependency on a specific version of `nanoid`.
1453
- */
1454
- const urlAlphabet =
1455
- "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
1456
-
1457
- /**
1458
- * Converts a Base64Url string to a Uint8Array for binary storage. This encoding
1459
- * is more space-efficient than UTF-8 for Base64Url strings.
1460
- */
1461
- export const base64Url256ToBytes = (
1462
- string: Base64Url256Variant,
1463
- ): globalThis.Uint8Array => {
1464
- const totalBits = string.length * 6; // 6 bits per character
1465
- const byteLength = Math.ceil(totalBits / 8);
1466
- const value = new globalThis.Uint8Array(byteLength);
1467
-
1468
- let bitBuffer = 0;
1469
- let bitsInBuffer = 0;
1470
- let byteIndex = 0;
1471
-
1472
- for (const char of string) {
1473
- const charValue = urlAlphabet.indexOf(char);
1474
- bitBuffer = (bitBuffer << 6) | charValue;
1475
- bitsInBuffer += 6;
1476
- while (bitsInBuffer >= 8) {
1477
- bitsInBuffer -= 8;
1478
- value[byteIndex++] = (bitBuffer >> bitsInBuffer) & 0xff;
1479
- }
1480
- }
1481
-
1482
- if (bitsInBuffer > 0 && byteIndex < byteLength) {
1483
- value[byteIndex] = (bitBuffer << (8 - bitsInBuffer)) & 0xff;
1484
- }
1485
-
1486
- return value;
1515
+ const decodeId = (buffer: Buffer): Id => {
1516
+ const bytes = buffer.shiftN(idBytesTypeValueLength);
1517
+ return idBytesToId(bytes as IdBytes);
1487
1518
  };
1488
1519
 
1489
- export const decodeBase64Url256 = (
1490
- buffer: Buffer,
1491
- stringLength: number,
1492
- ): Base64Url256Variant => {
1493
- const bytes = buffer.shiftN(
1494
- Math.ceil((stringLength * 6) / 8) as NonNegativeInt,
1495
- );
1496
-
1497
- let bitBuffer = 0;
1498
- let bitsInBuffer = 0;
1499
- let string = "";
1500
-
1501
- for (const byte of bytes) {
1502
- bitBuffer = (bitBuffer << 8) | byte;
1503
- bitsInBuffer += 8;
1504
- while (bitsInBuffer >= 6) {
1505
- bitsInBuffer -= 6;
1506
- if (string.length < stringLength) {
1507
- const charValue = (bitBuffer >> bitsInBuffer) & 0x3f;
1508
- if (charValue < 0 || charValue >= urlAlphabet.length) {
1509
- throw new ProtocolDecodeError("invalid charValue");
1510
- }
1511
- string += urlAlphabet[charValue];
1512
- }
1513
- }
1514
- }
1515
-
1516
- const result = Base64Url256.from(string);
1517
- if (!result.ok) throw new ProtocolDecodeError(result.error.type);
1518
-
1519
- return result.value;
1520
- };
1521
-
1522
- const decodeId = (buffer: Buffer): Id =>
1523
- decodeBase64Url256(buffer, idTypeValueLength) as Id;
1524
-
1525
- /** Not all 16 bytes are valid {@link OwnerId}. */
1526
- const decodeOwnerId = (buffer: Buffer): OwnerId => decodeId(buffer) as OwnerId;
1527
-
1528
1520
  /**
1529
1521
  * Evolu uses MessagePack to handle all number variants except for
1530
1522
  * NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
1531
1523
  */
1532
1524
  export const encodeNumber = (buffer: Buffer, number: number): void => {
1533
- buffer.extend(pack(number));
1525
+ buffer.extend(packr.pack(number));
1534
1526
  };
1535
1527
 
1536
1528
  export const decodeNumber = (buffer: Buffer): number => {
1537
1529
  let number: unknown;
1538
1530
  let end: unknown;
1539
1531
 
1540
- unpackMultiple(buffer.unwrap(), (n, _, e) => {
1541
- number = n;
1542
- end = e;
1543
- return false;
1544
- });
1532
+ packr.unpackMultiple(
1533
+ buffer.unwrap(),
1534
+ (n: unknown, _: unknown, e: unknown) => {
1535
+ number = n;
1536
+ end = e;
1537
+ return false;
1538
+ },
1539
+ );
1545
1540
 
1546
1541
  const endResult = NonNegativeInt.fromUnknown(end);
1547
1542
  if (!endResult.ok) throw new ProtocolDecodeError(endResult.error.type);
@@ -1553,28 +1548,33 @@ export const decodeNumber = (buffer: Buffer): number => {
1553
1548
  return numberResult.value;
1554
1549
  };
1555
1550
 
1556
- export const binaryTimestampToFingerprint = (
1557
- timestamp: BinaryTimestamp,
1558
- ): Fingerprint => {
1559
- const hash = sha256(timestamp).slice(0, fingerprintSize);
1560
- return hash as Fingerprint;
1561
- };
1562
-
1563
1551
  /**
1564
1552
  * Encodes and encrypts a {@link DbChange} using the provided owner's encryption
1565
1553
  * key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
1554
+ *
1555
+ * The format includes the protocol version for backward compatibility and the
1556
+ * timestamp for tamper-proof verification that the timestamp matches the change
1557
+ * data.
1566
1558
  */
1567
1559
  export const encodeAndEncryptDbChange =
1568
1560
  (deps: SymmetricCryptoDep) =>
1569
- (change: DbChange, key: EncryptionKey): EncryptedDbChange => {
1561
+ (message: CrdtMessage, key: EncryptionKey): EncryptedDbChange => {
1562
+ const change = message.change;
1570
1563
  const buffer = createBuffer();
1571
1564
 
1572
- encodeBase64Url256(buffer, change.table);
1565
+ // Encode protocol version first for backward compatibility
1566
+ encodeNonNegativeInt(buffer, protocolVersion);
1567
+
1568
+ // Encode the timestamp (after version) for tamper verification
1569
+ const timestampBytes = timestampToTimestampBytes(message.timestamp);
1570
+ buffer.extend(timestampBytes);
1573
1571
 
1574
- buffer.extend(idToBinaryId(change.id));
1572
+ encodeString(buffer, change.table);
1573
+
1574
+ buffer.extend(idToIdBytes(change.id));
1575
1575
 
1576
1576
  const entries = objectToEntries(change.values).map(
1577
- ([column, value]): [Base64Url256, SqliteValue] => {
1577
+ ([column, value]): [string, SqliteValue] => {
1578
1578
  return [column, value];
1579
1579
  },
1580
1580
  );
@@ -1582,7 +1582,7 @@ export const encodeAndEncryptDbChange =
1582
1582
  encodeLength(buffer, entries);
1583
1583
 
1584
1584
  for (const [column, value] of entries) {
1585
- encodeBase64Url256(buffer, column);
1585
+ encodeString(buffer, column);
1586
1586
  encodeSqliteValue(buffer, value);
1587
1587
  }
1588
1588
 
@@ -1604,50 +1604,80 @@ export const encodeAndEncryptDbChange =
1604
1604
  };
1605
1605
 
1606
1606
  /**
1607
- * Decrypts and decodes an {@link EncryptedDbChange} using the provided owner's
1608
- * encryption key.
1607
+ * Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
1608
+ * owner's encryption key. Verifies that the embedded timestamp matches the
1609
+ * expected timestamp to ensure message integrity.
1609
1610
  */
1610
1611
  export const decryptAndDecodeDbChange =
1611
1612
  (deps: SymmetricCryptoDep) =>
1612
1613
  (
1613
- change: EncryptedDbChange,
1614
+ message: EncryptedCrdtMessage,
1614
1615
  key: EncryptionKey,
1615
- ): Result<DbChange, SymmetricCryptoDecryptError | ProtocolInvalidDataError> =>
1616
- tryDecodeProtocolData<DbChange, SymmetricCryptoDecryptError>(
1617
- change,
1618
- (buffer) => {
1619
- const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1620
-
1621
- const ciphertextLength = decodeLength(buffer);
1622
- const ciphertext = buffer.shiftN(ciphertextLength);
1623
-
1624
- const plaintextBytes = deps.symmetricCrypto.decrypt(
1625
- ciphertext,
1626
- key,
1627
- nonce,
1628
- );
1629
- if (!plaintextBytes.ok) return plaintextBytes;
1616
+ ): Result<
1617
+ DbChange,
1618
+ | SymmetricCryptoDecryptError
1619
+ | ProtocolInvalidDataError
1620
+ | ProtocolTimestampMismatchError
1621
+ > => {
1622
+ // try-catch instead of Result for performance and stacktraces
1623
+ try {
1624
+ const buffer = createBuffer(message.change);
1625
+ const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1626
+
1627
+ const ciphertextLength = decodeLength(buffer);
1628
+ const ciphertext = buffer.shiftN(ciphertextLength);
1629
+
1630
+ const plaintextBytes = deps.symmetricCrypto.decrypt(
1631
+ ciphertext,
1632
+ key,
1633
+ nonce,
1634
+ );
1635
+ if (!plaintextBytes.ok) return plaintextBytes;
1630
1636
 
1631
- buffer.reset();
1632
- buffer.extend(plaintextBytes.value);
1637
+ buffer.reset();
1638
+ buffer.extend(plaintextBytes.value);
1633
1639
 
1634
- const table = decodeBase64Url256WithLength(buffer);
1635
- const id = decodeId(buffer);
1640
+ // Decode version (for future compatibility, no validation needed for now)
1641
+ decodeNonNegativeInt(buffer);
1636
1642
 
1637
- const length = decodeLength(buffer);
1638
- const values = Object.create(null) as Record<string, SqliteValue>;
1643
+ // Decode and verify the embedded timestamp
1644
+ const embeddedTimestampBytes = buffer.shiftN(timestampBytesLength);
1645
+ const embeddedTimestamp = timestampBytesToTimestamp(
1646
+ embeddedTimestampBytes as TimestampBytes,
1647
+ );
1639
1648
 
1640
- for (let i = 0; i < length; i++) {
1641
- const column = decodeBase64Url256WithLength(buffer);
1642
- const value = decodeSqliteValue(buffer);
1643
- values[column] = value;
1644
- }
1649
+ // Verify timestamp integrity
1650
+ if (!eqTimestamp(embeddedTimestamp, message.timestamp)) {
1651
+ return err<ProtocolTimestampMismatchError>({
1652
+ type: "ProtocolTimestampMismatchError",
1653
+ expected: message.timestamp,
1654
+ embedded: embeddedTimestamp,
1655
+ });
1656
+ }
1645
1657
 
1646
- const dbChange = { table, id, values };
1658
+ const table = decodeString(buffer);
1659
+ const id = decodeId(buffer);
1647
1660
 
1648
- return ok(dbChange);
1649
- },
1650
- );
1661
+ const length = decodeLength(buffer);
1662
+ const values = Object.create(null) as Record<string, SqliteValue>;
1663
+
1664
+ for (let i = 0; i < length; i++) {
1665
+ const column = decodeString(buffer);
1666
+ const value = decodeSqliteValue(buffer);
1667
+ values[column] = value;
1668
+ }
1669
+
1670
+ const dbChange = { table, id, values };
1671
+
1672
+ return ok(dbChange);
1673
+ } catch (error) {
1674
+ return err<ProtocolInvalidDataError>({
1675
+ type: "ProtocolInvalidDataError",
1676
+ data: message.change,
1677
+ error,
1678
+ });
1679
+ }
1680
+ };
1651
1681
 
1652
1682
  /**
1653
1683
  * Encodes a non-negative integer into a variable-length integer format. It's
@@ -1731,19 +1761,6 @@ export const decodeNodeId = (buffer: Buffer): NodeId => {
1731
1761
  return bytesToHex(bytes) as NodeId;
1732
1762
  };
1733
1763
 
1734
- export const encodeBase64Url256 = (
1735
- buffer: Buffer,
1736
- string: Base64Url256Variant,
1737
- ): void => {
1738
- encodeLength(buffer, string);
1739
- buffer.extend(base64Url256ToBytes(string));
1740
- };
1741
-
1742
- export const decodeBase64Url256WithLength = (buffer: Buffer): Base64Url256 => {
1743
- const length = decodeLength(buffer);
1744
- return decodeBase64Url256(buffer, length) as Base64Url256;
1745
- };
1746
-
1747
1764
  // Small ints are encoded into ProtocolValueType, saving one byte per int.
1748
1765
  const isSmallInt: Predicate<number> = (value: number) =>
1749
1766
  value >= 0 && value < 20;
@@ -1755,20 +1772,23 @@ export const ProtocolValueType = {
1755
1772
  String: 20 as NonNegativeInt,
1756
1773
  Number: 21 as NonNegativeInt,
1757
1774
  Null: 22 as NonNegativeInt,
1758
- Binary: 23 as NonNegativeInt,
1775
+ Bytes: 23 as NonNegativeInt,
1759
1776
  // We can add more types for other DBs or anything else later.
1760
1777
 
1761
1778
  // Optimized types
1762
- Id: 30 as NonNegativeInt,
1763
- Base64Url256: 31 as NonNegativeInt,
1764
- NonNegativeInt: 32 as NonNegativeInt,
1765
- 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,
1766
1786
 
1767
1787
  // new Date().toISOString() - 24 bytes
1768
1788
  // encoded with fixed length - 8 bytes
1769
1789
  // encode as NonNegativeInt - 6 bytes (additional 25% reduction)
1770
- DateIsoWithNonNegativeTime: 34 as NonNegativeInt,
1771
- DateIsoWithNegativeTime: 35 as NonNegativeInt, // 9 bytes
1790
+ DateIsoWithNonNegativeTime: 35 as NonNegativeInt,
1791
+ DateIsoWithNegativeTime: 36 as NonNegativeInt, // 9 bytes
1772
1792
 
1773
1793
  // TODO: Operations (from 40)
1774
1794
  // Increment, Decrement, Patch, whatever.
@@ -1782,9 +1802,14 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
1782
1802
 
1783
1803
  switch (typeof value) {
1784
1804
  case "string": {
1785
- const dateIsoString = DateIsoString.from(value);
1786
- if (dateIsoString.ok) {
1787
- 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();
1788
1813
  if (NonNegativeInt.is(time)) {
1789
1814
  encodeNonNegativeInt(
1790
1815
  buffer,
@@ -1801,27 +1826,34 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
1801
1826
  return;
1802
1827
  }
1803
1828
 
1804
- const base64Url256 = Base64Url256.from(value);
1805
- if (base64Url256.ok) {
1806
- if (base64Url256.value.length === idTypeValueLength) {
1807
- encodeNonNegativeInt(buffer, ProtocolValueType.Id);
1808
- buffer.extend(base64Url256ToBytes(base64Url256.value));
1809
- return;
1810
- }
1811
- encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url256);
1812
- 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));
1813
1833
  return;
1814
1834
  }
1815
1835
 
1816
- const jsonValue = JsonValueFromString.fromParent(value);
1817
- if (jsonValue.ok) {
1818
- 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));
1819
1842
  encodeNonNegativeInt(buffer, ProtocolValueType.Json);
1820
1843
  encodeLength(buffer, jsonBytes);
1821
1844
  buffer.extend(jsonBytes);
1822
1845
  return;
1823
1846
  }
1824
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
+
1825
1857
  encodeNonNegativeInt(buffer, ProtocolValueType.String);
1826
1858
  encodeString(buffer, value);
1827
1859
  return;
@@ -1843,7 +1875,7 @@ export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
1843
1875
  }
1844
1876
  }
1845
1877
 
1846
- encodeNonNegativeInt(buffer, ProtocolValueType.Binary);
1878
+ encodeNonNegativeInt(buffer, ProtocolValueType.Bytes);
1847
1879
  encodeLength(buffer, value);
1848
1880
  buffer.extend(value);
1849
1881
  };
@@ -1858,40 +1890,75 @@ export const decodeSqliteValue = (buffer: Buffer): SqliteValue => {
1858
1890
  switch (type) {
1859
1891
  case ProtocolValueType.String:
1860
1892
  return decodeString(buffer);
1893
+
1861
1894
  case ProtocolValueType.Number:
1862
1895
  return decodeNumber(buffer);
1896
+
1863
1897
  case ProtocolValueType.Null:
1864
1898
  return null;
1865
- case ProtocolValueType.Binary: {
1899
+
1900
+ case ProtocolValueType.Bytes: {
1866
1901
  const length = decodeLength(buffer);
1867
1902
  return buffer.shiftN(length);
1868
1903
  }
1869
- case ProtocolValueType.Id: {
1904
+
1905
+ case ProtocolValueType.Id:
1870
1906
  return decodeId(buffer);
1871
- }
1872
- case ProtocolValueType.Base64Url256:
1873
- return decodeBase64Url256WithLength(buffer);
1907
+
1874
1908
  case ProtocolValueType.NonNegativeInt:
1875
1909
  return decodeNonNegativeInt(buffer);
1910
+
1876
1911
  case ProtocolValueType.Json: {
1877
1912
  const length = decodeLength(buffer);
1878
1913
  const bytes = buffer.shiftN(length);
1879
- return JSON.stringify(unpack(bytes));
1914
+ return JSON.stringify(packr.unpack(bytes));
1880
1915
  }
1916
+
1881
1917
  case ProtocolValueType.DateIsoWithNonNegativeTime:
1882
1918
  case ProtocolValueType.DateIsoWithNegativeTime: {
1883
1919
  const time =
1884
1920
  type === ProtocolValueType.DateIsoWithNonNegativeTime
1885
1921
  ? decodeNonNegativeInt(buffer)
1886
1922
  : decodeNumber(buffer);
1887
- const dateIsoString = DateIsoString.fromParent(
1888
- new Date(time).toISOString(),
1889
- );
1890
- if (!dateIsoString.ok)
1891
- throw new ProtocolDecodeError(dateIsoString.error.type);
1892
- 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);
1893
1935
  }
1936
+
1894
1937
  default:
1895
1938
  throw new ProtocolDecodeError("invalid ProtocolValueType");
1896
1939
  }
1897
1940
  };
1941
+
1942
+ /**
1943
+ * Decodes a ProtocolMessage into a readable JSON object for debugging.
1944
+ *
1945
+ * Note: This is a stub for future implementation. It should use:
1946
+ *
1947
+ * - DecodeVersionAndOwner
1948
+ * - DecodeError or decodeWriteKeys (depending on context)
1949
+ * - DecodeMessages
1950
+ * - DecodeRanges
1951
+ *
1952
+ * If you want to help, please contribute to this function.
1953
+ */
1954
+ export const decodeProtocolMessageToJson = (
1955
+ _protocolMessage: ProtocolMessage,
1956
+ _isInitiator: boolean,
1957
+ ): unknown => {
1958
+ // TODO: Implement using
1959
+ // - decodeVersionAndOwner
1960
+ // -- decodeError or decodeWriteKeys (should be refactored out),
1961
+ // -- decodeMessages, and decodeRanges.
1962
+ // This is a stub for PRs and community contributions.
1963
+ throw new Error("decodeProtocolMessageToJson is not implemented yet.");
1964
+ };