@evolu/common 6.0.1-preview.8 → 7.0.0

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 (184) hide show
  1. package/README.md +2 -2
  2. package/dist/src/Array.d.ts +256 -12
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +215 -9
  5. package/dist/src/Assert.d.ts +0 -13
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +0 -15
  8. package/dist/src/Brand.d.ts +75 -0
  9. package/dist/src/Brand.d.ts.map +1 -0
  10. package/dist/src/Brand.js +1 -0
  11. package/dist/src/Buffer.d.ts +1 -1
  12. package/dist/src/Buffer.d.ts.map +1 -1
  13. package/dist/src/Buffer.js +8 -7
  14. package/dist/src/Cache.d.ts +44 -0
  15. package/dist/src/Cache.d.ts.map +1 -0
  16. package/dist/src/Cache.js +52 -0
  17. package/dist/src/Callbacks.d.ts +45 -12
  18. package/dist/src/Callbacks.d.ts.map +1 -1
  19. package/dist/src/Callbacks.js +14 -7
  20. package/dist/src/Console.d.ts +31 -6
  21. package/dist/src/Console.d.ts.map +1 -1
  22. package/dist/src/Console.js +72 -9
  23. package/dist/src/Crypto.d.ts +56 -42
  24. package/dist/src/Crypto.d.ts.map +1 -1
  25. package/dist/src/Crypto.js +40 -53
  26. package/dist/src/Evolu/Db.d.ts +162 -74
  27. package/dist/src/Evolu/Db.d.ts.map +1 -1
  28. package/dist/src/Evolu/Db.js +284 -702
  29. package/dist/src/Evolu/Diff.d.ts +3 -3
  30. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  31. package/dist/src/Evolu/Diff.js +7 -5
  32. package/dist/src/Evolu/Evolu.d.ts +214 -134
  33. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  34. package/dist/src/Evolu/Evolu.js +189 -180
  35. package/dist/src/Evolu/Internal.d.ts +0 -2
  36. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  37. package/dist/src/Evolu/Internal.js +0 -2
  38. package/dist/src/Evolu/LocalAuth.d.ts +150 -0
  39. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  40. package/dist/src/Evolu/LocalAuth.js +174 -0
  41. package/dist/src/Evolu/Owner.d.ts +273 -120
  42. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  43. package/dist/src/Evolu/Owner.js +130 -104
  44. package/dist/src/Evolu/Platform.d.ts +9 -7
  45. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  46. package/dist/src/Evolu/Protocol.d.ts +268 -240
  47. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  48. package/dist/src/Evolu/Protocol.js +600 -454
  49. package/dist/src/Evolu/Public.d.ts +6 -8
  50. package/dist/src/Evolu/Public.d.ts.map +1 -1
  51. package/dist/src/Evolu/Public.js +2 -3
  52. package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
  53. package/dist/src/Evolu/PublicKysely.js +3 -4
  54. package/dist/src/Evolu/Query.d.ts +2 -1
  55. package/dist/src/Evolu/Query.d.ts.map +1 -1
  56. package/dist/src/Evolu/Query.js +1 -1
  57. package/dist/src/Evolu/Relay.d.ts +91 -8
  58. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  59. package/dist/src/Evolu/Relay.js +214 -88
  60. package/dist/src/Evolu/Schema.d.ts +125 -47
  61. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  62. package/dist/src/Evolu/Schema.js +175 -31
  63. package/dist/src/Evolu/Storage.d.ts +249 -27
  64. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  65. package/dist/src/Evolu/Storage.js +198 -92
  66. package/dist/src/Evolu/Sync.d.ts +68 -13
  67. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  68. package/dist/src/Evolu/Sync.js +469 -20
  69. package/dist/src/Evolu/Timestamp.d.ts +83 -30
  70. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  71. package/dist/src/Evolu/Timestamp.js +79 -34
  72. package/dist/src/Identicon.d.ts +35 -0
  73. package/dist/src/Identicon.d.ts.map +1 -0
  74. package/dist/src/Identicon.js +143 -0
  75. package/dist/src/Instances.d.ts +34 -0
  76. package/dist/src/Instances.d.ts.map +1 -0
  77. package/dist/src/Instances.js +44 -0
  78. package/dist/src/Number.d.ts +4 -3
  79. package/dist/src/Number.d.ts.map +1 -1
  80. package/dist/src/Number.js +5 -4
  81. package/dist/src/Object.d.ts +10 -4
  82. package/dist/src/Object.d.ts.map +1 -1
  83. package/dist/src/Object.js +9 -3
  84. package/dist/src/Platform.d.ts +20 -0
  85. package/dist/src/Platform.d.ts.map +1 -0
  86. package/dist/src/Platform.js +22 -0
  87. package/dist/src/Random.d.ts +3 -2
  88. package/dist/src/Random.d.ts.map +1 -1
  89. package/dist/src/Relation.d.ts +79 -0
  90. package/dist/src/Relation.d.ts.map +1 -0
  91. package/dist/src/Relation.js +127 -0
  92. package/dist/src/Resources.d.ts +118 -0
  93. package/dist/src/Resources.d.ts.map +1 -0
  94. package/dist/src/Resources.js +197 -0
  95. package/dist/src/Result.d.ts +184 -52
  96. package/dist/src/Result.d.ts.map +1 -1
  97. package/dist/src/Result.js +30 -241
  98. package/dist/src/Skiplist.js +2 -1
  99. package/dist/src/Sqlite.d.ts +89 -5
  100. package/dist/src/Sqlite.d.ts.map +1 -1
  101. package/dist/src/Sqlite.js +136 -9
  102. package/dist/src/Task.d.ts +586 -0
  103. package/dist/src/Task.d.ts.map +1 -0
  104. package/dist/src/Task.js +469 -0
  105. package/dist/src/Time.d.ts +66 -1
  106. package/dist/src/Time.d.ts.map +1 -1
  107. package/dist/src/Time.js +99 -5
  108. package/dist/src/Type.d.ts +676 -343
  109. package/dist/src/Type.d.ts.map +1 -1
  110. package/dist/src/Type.js +718 -467
  111. package/dist/src/Types.d.ts +1 -75
  112. package/dist/src/Types.d.ts.map +1 -1
  113. package/dist/src/WebSocket.d.ts +5 -2
  114. package/dist/src/WebSocket.d.ts.map +1 -1
  115. package/dist/src/WebSocket.js +12 -18
  116. package/dist/src/Worker.d.ts +39 -11
  117. package/dist/src/Worker.d.ts.map +1 -1
  118. package/dist/src/Worker.js +22 -4
  119. package/dist/src/index.d.ts +8 -3
  120. package/dist/src/index.d.ts.map +1 -1
  121. package/dist/src/index.js +8 -3
  122. package/package.json +15 -14
  123. package/src/Array.ts +301 -19
  124. package/src/Assert.ts +0 -21
  125. package/src/Brand.ts +75 -0
  126. package/src/Buffer.ts +7 -7
  127. package/src/Cache.ts +85 -0
  128. package/src/Callbacks.ts +62 -22
  129. package/src/Console.ts +91 -11
  130. package/src/Crypto.ts +94 -90
  131. package/src/Evolu/Db.ts +519 -1026
  132. package/src/Evolu/Diff.ts +7 -5
  133. package/src/Evolu/Evolu.ts +477 -361
  134. package/src/Evolu/Internal.ts +0 -2
  135. package/src/Evolu/LocalAuth.ts +463 -0
  136. package/src/Evolu/Owner.ts +355 -228
  137. package/src/Evolu/Platform.ts +9 -9
  138. package/src/Evolu/Protocol.ts +842 -742
  139. package/src/Evolu/Public.ts +7 -14
  140. package/src/Evolu/PublicKysely.ts +4 -5
  141. package/src/Evolu/Query.ts +3 -2
  142. package/src/Evolu/Relay.ts +406 -103
  143. package/src/Evolu/Schema.ts +323 -91
  144. package/src/Evolu/Storage.ts +559 -137
  145. package/src/Evolu/Sync.ts +819 -36
  146. package/src/Evolu/Timestamp.ts +90 -58
  147. package/src/Identicon.ts +197 -0
  148. package/src/Instances.ts +90 -0
  149. package/src/Number.ts +6 -10
  150. package/src/Object.ts +13 -5
  151. package/src/Platform.ts +26 -0
  152. package/src/Random.ts +3 -2
  153. package/src/Relation.ts +234 -0
  154. package/src/Resources.ts +367 -0
  155. package/src/Result.ts +191 -54
  156. package/src/Skiplist.ts +1 -1
  157. package/src/Sqlite.ts +152 -17
  158. package/src/Task.ts +901 -0
  159. package/src/Time.ts +180 -5
  160. package/src/Type.ts +1135 -730
  161. package/src/Types.ts +1 -77
  162. package/src/WebSocket.ts +27 -25
  163. package/src/Worker.ts +72 -23
  164. package/src/index.ts +8 -3
  165. package/dist/src/Evolu/Config.d.ts +0 -69
  166. package/dist/src/Evolu/Config.d.ts.map +0 -1
  167. package/dist/src/Evolu/Config.js +0 -9
  168. package/dist/src/Evolu/Kysely.d.ts +0 -6
  169. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  170. package/dist/src/Evolu/Kysely.js +0 -21
  171. package/dist/src/ManyToManyMap.d.ts +0 -26
  172. package/dist/src/ManyToManyMap.d.ts.map +0 -1
  173. package/dist/src/ManyToManyMap.js +0 -92
  174. package/dist/src/NanoId.d.ts +0 -27
  175. package/dist/src/NanoId.d.ts.map +0 -1
  176. package/dist/src/NanoId.js +0 -6
  177. package/dist/src/Promise.d.ts +0 -180
  178. package/dist/src/Promise.d.ts.map +0 -1
  179. package/dist/src/Promise.js +0 -176
  180. package/src/Evolu/Config.ts +0 -83
  181. package/src/Evolu/Kysely.ts +0 -38
  182. package/src/ManyToManyMap.ts +0 -140
  183. package/src/NanoId.ts +0 -39
  184. package/src/Promise.ts +0 -295
@@ -3,29 +3,39 @@
3
3
  *
4
4
  * Evolu Protocol is a local-first, end-to-end encrypted binary synchronization
5
5
  * 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.
6
+ * between a client and a relay. In the future, direct peer-to-peer (P2P) sync
7
+ * between clients will be possible without a relay.
8
+ *
9
+ * Relays don't need to sync with each other—clients using those relays will
10
+ * sync them eventually. If a relay is offline (e.g., for maintenance), it will
11
+ * sync automatically later via client sync logic. For relay backup using
12
+ * SQLite, see https://sqlite.org/rsync.html (uses a similar algorithm to Evolu
13
+ * RBSR).
8
14
  *
9
15
  * Evolu Protocol is designed for SQLite but can be extended to any database. It
10
16
  * implements [Range-Based Set
11
17
  * Reconciliation](https://arxiv.org/abs/2212.13567). To learn how RBSR works,
12
18
  * check [Negentropy](https://logperiodic.com/rbsr.html). Evolu Protocol is
13
19
  * similar to Negentropy but uses different encoding and also provides data
14
- * transfer and ownership.
20
+ * transfer, ownership, real-time broadcasting, request-response semantics, and
21
+ * error handling.
15
22
  *
16
- * ### Message Structure
23
+ * ### Message structure
17
24
  *
18
25
  * | Field | Notes |
19
26
  * | :----------------------------- | :------------------------ |
20
27
  * | **Header** | |
21
28
  * | - {@link protocolVersion} | |
22
29
  * | - {@link OwnerId} | {@link Owner} |
23
- * | **Initiator** | |
24
- * | - {@link WriteKeyMode} | |
25
- * | - {@link WriteKey} | If WriteKeyMode >= 1 |
26
- * | - {@link WriteKey} | If WriteKeyMode = 2 (new) |
27
- * | **Non-initiator** | |
30
+ * | - messageType | {@link MessageType} |
31
+ * | **Request (messageType=0)** | |
32
+ * | - hasWriteKey | 0 = no, 1 = yes |
33
+ * | - {@link OwnerWriteKey} | If hasWriteKey = 1 |
34
+ * | - subscriptionFlag | {@link SubscriptionFlags} |
35
+ * | **Response (messageType=1)** | |
28
36
  * | - {@link ProtocolErrorCode} | |
37
+ * | **Broadcast (messageType=2)** | |
38
+ * | - (no additional fields) | |
29
39
  * | **Messages** | |
30
40
  * | - {@link NonNegativeInt} | A number of messages. |
31
41
  * | - {@link EncryptedCrdtMessage} | |
@@ -33,21 +43,14 @@
33
43
  * | - {@link NonNegativeInt} | Number of ranges. |
34
44
  * | - {@link Range} | |
35
45
  *
36
- * ### WriteKey Validation
37
- *
38
- * The initiator sends WriteKeyMode and optionally one or two WriteKeys. One key
39
- * for write operations and two for key rotation (current and new). Note that
40
- * it's ok to not send any key if initiator is going to be synced with readonly
41
- * owner. The non-initiator validates them immediately after parsing the
42
- * initiator header, before processing any messages or ranges.
43
- *
44
- * ### WriteKey Rotation
46
+ * ### WriteKey validation
45
47
  *
46
- * When initiator's {@link WriteKeyMode} is `Rotation`, two WriteKeys are
47
- * present:
48
- *
49
- * 1. Current WriteKey (for validation)
50
- * 2. New WriteKey (to be stored)
48
+ * The initiator sends a hasWriteKey flag and optionally a WriteKey. The
49
+ * WriteKey is required when sending messages as a secure token proving the
50
+ * initiator can write changes. It's ok to not send a WriteKey if the initiator
51
+ * is only syncing (read-only) and not sending messages. The non-initiator
52
+ * validates the WriteKey immediately after parsing the initiator header, before
53
+ * processing any messages or ranges.
51
54
  *
52
55
  * ### Synchronization
53
56
  *
@@ -61,38 +64,48 @@
61
64
  * if further sync is needed or possible, continuing until both sides are
62
65
  * synchronized.
63
66
  *
67
+ * The **non-initiator always responds** to provide sync completion feedback,
68
+ * even with empty messages containing only the header and no error. This allows
69
+ * the initiator to detect when synchronization is complete.
70
+ *
64
71
  * Both **Messages** and **Ranges** are optional, allowing each side to send,
65
72
  * sync, or only subscribe data as needed.
66
73
  *
67
- * When the initiator sends data, the {@link WriteKey} is required in Messages as
68
- * a secure token proving the initiator can write changes. The non-initiator
69
- * responds without a {@link WriteKey}, since the initiator’s request already
70
- * signals it wants data. If the non-initiator detects an issue, it sends an
71
- * error code via the `Error` field in the header back to the initiator. In
72
- * relay-to-relay or P2P sync, both sides may require the {@link WriteKey}
73
- * depending on who is the initiator.
74
+ * When the initiator sends data, the {@link OwnerWriteKey} is required as a
75
+ * secure token proving the initiator can write changes. The non-initiator
76
+ * responds without a {@link OwnerWriteKey}, since the initiator’s request
77
+ * already signals it wants data. If the non-initiator detects an issue, it
78
+ * sends an error code via the `Error` field in the header back to the
79
+ * initiator. In relay-to-relay or P2P sync, both sides may require the
80
+ * {@link OwnerWriteKey} depending on who is the initiator.
74
81
  *
75
- * ### Protocol Errors
82
+ * ### Protocol errors
76
83
  *
77
84
  * The protocol uses error codes in the header to signal issues:
78
85
  *
79
86
  * - {@link ProtocolWriteKeyError}: The provided WriteKey is invalid or missing.
80
- * - {@link ProtocolWriteError}: A write operation failed (e.g., due to storage
81
- * limits or billing).
82
- * - {@link ProtocolSyncError}: A generic or unexpected synchronization failure
87
+ * - {@link ProtocolWriteError}: A serious relay-side write failure occurred.
88
+ * - {@link ProtocolQuotaError}: Storage or billing quota exceeded.
89
+ * - {@link ProtocolSyncError}: A serious relay-side synchronization failure
83
90
  * occurred.
84
- * - {@link ProtocolUnsupportedVersionError}: Protocol version mismatch.
91
+ * - {@link ProtocolVersionError}: Protocol version mismatch.
85
92
  * - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
86
93
  *
87
- * All protocol errors except `ProtocolInvalidDataError` include the `ownerId`
94
+ * All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
88
95
  * to allow clients to associate errors with the correct owner.
89
96
  *
90
- * ### Message Size Limit
97
+ * ### Message size limit
91
98
  *
92
99
  * The protocol enforces a strict maximum size for all messages, defined by
93
- * {@link maxProtocolMessageSize}. This ensures every {@link ProtocolMessage} is
94
- * less than or equal to this limit, eliminating the need for applications to
95
- * fragment and reconstruct messages during transmission.
100
+ * {@link ProtocolMessageMaxSize}. This ensures every {@link ProtocolMessage} is
101
+ * less than or equal to this limit, enabling stateless transports, simplified
102
+ * relay implementation, and predictable memory usage. When all messages don't
103
+ * fit within the limit, the protocol automatically continues synchronization in
104
+ * subsequent rounds using range-based reconciliation.
105
+ *
106
+ * Database mutations are limited to 640KB, which is smaller than the protocol
107
+ * message limit to ensure efficient sync with
108
+ * {@link defaultProtocolMessageRangesMaxSize}.
96
109
  *
97
110
  * ### Why Binary?
98
111
  *
@@ -100,13 +113,12 @@
100
113
  *
101
114
  * - Encrypted data doesn’t compress well, unlike plain JSON.
102
115
  * - Message size must be controlled during creation.
103
- * - Sequential byte reading is faster than parsing and can avoid conversions.
116
+ * - Sequential byte reading is faster than parsing and avoids conversions.
104
117
  *
105
118
  * It uses structure-aware encoding, significantly outperforming generic binary
106
119
  * serialization formats with the following optimizations:
107
120
  *
108
121
  * - **NonNegativeInt:** Up to 33% smaller than MessagePack.
109
- * - **Base64Url Strings:** Up to 25% size reduction.
110
122
  * - **DateIso:** Up to 75% smaller.
111
123
  * - **Timestamp Encoding:** Delta encoding for milliseconds and run-length
112
124
  * encoding (RLE) for counters and NodeIds.
@@ -136,81 +148,143 @@
136
148
  * Version negotiation is per-owner, allowing Evolu Protocol to evolve safely
137
149
  * over time and provide clear feedback about version mismatches.
138
150
  *
151
+ * ### Credible exit
152
+ *
153
+ * The protocol specification is intentionally non-configurable to ensure
154
+ * universal compatibility. This design allows applications (users) to switch
155
+ * between any compliant relay without negotiation or compatibility checks
156
+ * beyond version matching. Relays are generic infrastructure that any
157
+ * application can use interchangeably making exit from any single provider
158
+ * technically feasible and economically viable.
159
+ *
139
160
  * @module
140
161
  */
141
- import { sha256 } from "@noble/hashes/sha2";
142
- import { pack, unpack, unpackMultiple } from "msgpackr";
162
+ /**
163
+ * TODO:
164
+ *
165
+ * - The client-relay naming convention in functions like
166
+ * `applyProtocolMessageAsClient` and `applyProtocolMessageAsRelay` is not
167
+ * ideal. In the future, clients will be able to sync directly with each other
168
+ * (P2P), making the current naming misleading. Consider using
169
+ * initiator/non-initiator terminology instead, and consolidate into a single
170
+ * `applyProtocolMessage` function with conditional arguments to reduce code
171
+ * duplication.
172
+ * - Replace try-catch with Result + new Error (to preserve stacktraces). Measure
173
+ * Result overhead, it should be super small.
174
+ * - Allow clients to broadcast messages that are not persisted by relays. This
175
+ * would enable real-time ephemeral data (like cursor positions, typing
176
+ * indicators) to be forwarded by relays without storage overhead.
177
+ */
178
+ import { Packr } from "msgpackr";
143
179
  import { isNonEmptyReadonlyArray } from "../Array.js";
144
180
  import { assert } from "../Assert.js";
145
- import { BufferError, bytesToHex, bytesToUtf8, createBuffer, hexToBytes, utf8ToBytes, } from "../Buffer.js";
146
- import { padmePaddingLength, } from "../Crypto.js";
181
+ import { bytesToHex, bytesToUtf8, createBuffer, hexToBytes, utf8ToBytes, } from "../Buffer.js";
182
+ import { createPadmePadding, } from "../Crypto.js";
147
183
  import { eqArrayNumber } from "../Eq.js";
148
184
  import { computeBalancedBuckets } from "../Number.js";
149
185
  import { objectToEntries } from "../Object.js";
150
186
  import { err, ok } from "../Result.js";
151
- import { SqliteValue } from "../Sqlite.js";
152
- import { Base64Url, DateIsoString, Id, idTypeValueLength, JsonValueFromString, maxLength, NonNegativeInt, Number, object, record, } from "../Type.js";
153
- import { writeKeyLength, } from "./Owner.js";
154
- import { binaryTimestampToTimestamp, Counter, Millis, timestampToBinaryTimestamp, } from "./Timestamp.js";
155
- /** Maximum size of the entire protocol message in bytes. */
156
- export const maxProtocolMessageSize = 1_000_000;
157
- /** Maximum size of the ranges in bytes. */
158
- export const maxProtocolMessageRangesSize = 30_000;
187
+ import { Base64Url, base64UrlToUint8Array, between, DateIso, Id, idBytesToId, idBytesTypeValueLength, idToIdBytes, Int, Json, jsonToJsonValue, NonNegativeInt, Number, PositiveInt, uint8ArrayToBase64Url, } from "../Type.js";
188
+ import { ownerIdToOwnerIdBytes, ownerWriteKeyLength, } from "./Owner.js";
189
+ import { DbChange, fingerprintSize, InfiniteUpperBound, RangeType, } from "./Storage.js";
190
+ import { Counter, eqTimestamp, Millis, timestampBytesLength, timestampBytesToTimestamp, timestampToTimestampBytes, } from "./Timestamp.js";
191
+ /**
192
+ * Evolu uses MessagePack for numbers and JSONs.
193
+ *
194
+ * - `variableMapSize: true` - More compact maps, ~5-10% slower encoding
195
+ * - `useRecords: false` - Standard MessagePack without extensions
196
+ */
197
+ const packr = new Packr({ variableMapSize: true, useRecords: false });
198
+ const minProtocolMessageMaxSize = 1_000_000;
199
+ const maxProtocolMessageMaxSize = 100_000_000;
200
+ /**
201
+ * Protocol message maximum size.
202
+ *
203
+ * Defines the upper limit for how large a single protocol message can be.
204
+ * Implementations must enforce a maximum size between 1MB and 100MB to ensure
205
+ * compatibility across all Evolu implementations (the maximum size of mutation
206
+ * change is hardcoded and enforced hence the maximum size can't be smaller).
207
+ *
208
+ * Larger maximum sizes can be configured by relays to reduce roundtrips. For
209
+ * example, a dedicated relay with ample resources could configure a 100MB
210
+ * maximum to minimize roundtrips for large syncs.
211
+ *
212
+ * Only relays can safely configure larger sizes, as clients will handle them.
213
+ * Increasing this value on the client side would break compatibility with
214
+ * relays that enforce smaller limits.
215
+ */
216
+ export const ProtocolMessageMaxSize = between(minProtocolMessageMaxSize, maxProtocolMessageMaxSize)(Int);
217
+ /**
218
+ * Default {@link ProtocolMessageMaxSize} (1MB).
219
+ *
220
+ * The standard size used across Evolu implementations. Relays with more
221
+ * resources can configure larger sizes to reduce roundtrips.
222
+ */
223
+ export const defaultProtocolMessageMaxSize = minProtocolMessageMaxSize;
224
+ /**
225
+ * Protocol message ranges maximum size.
226
+ *
227
+ * Defines the upper limit for how large the ranges section of a protocol
228
+ * message can be. Implementations must enforce a maximum size between 3KB and
229
+ * 100KB to ensure compatibility.
230
+ *
231
+ * The upper bound is set to ensure ranges fit within the default 1MB
232
+ * {@link defaultProtocolMessageMaxSize}, maintaining compatibility between all
233
+ * clients and relays.
234
+ */
235
+ export const ProtocolMessageRangesMaxSize = between(3_000, 100_000)(Int);
236
+ /**
237
+ * Default {@link ProtocolMessageRangesMaxSize} (30KB).
238
+ *
239
+ * The standard size used across Evolu implementations. Relays with more
240
+ * resources can configure larger sizes to reduce roundtrips.
241
+ */
242
+ export const defaultProtocolMessageRangesMaxSize = 30_000;
159
243
  /** Evolu Protocol version. */
160
- export const protocolVersion = 0;
244
+ export const protocolVersion = NonNegativeInt.orThrow(1);
245
+ export const MessageType = {
246
+ /** Request message from initiator (client) to non-initiator (relay). */
247
+ Request: 0,
248
+ /** Response message from non-initiator (relay) to initiator (client). */
249
+ Response: 1,
250
+ /** Broadcast message from non-initiator (relay) to subscribed clients. */
251
+ Broadcast: 2,
252
+ };
253
+ export const SubscriptionFlags = {
254
+ /** No subscription changes for this owner. */
255
+ None: 0,
256
+ /** Subscribe to updates for this owner. */
257
+ Subscribe: 1,
258
+ /** Unsubscribe from updates for this owner. */
259
+ Unsubscribe: 2,
260
+ };
161
261
  export const ProtocolErrorCode = {
162
262
  NoError: 0,
163
263
  /** A code for {@link ProtocolWriteKeyError}. */
164
264
  WriteKeyError: 1,
165
265
  /** A code for {@link ProtocolWriteError}. */
166
266
  WriteError: 2,
267
+ /** A code for {@link ProtocolQuotaError}. */
268
+ QuotaError: 3,
167
269
  /** A code for {@link ProtocolSyncError}. */
168
- SyncError: 3,
169
- };
170
- export const WriteKeyMode = {
171
- None: 0,
172
- Single: 1,
173
- Rotation: 2,
174
- };
175
- /**
176
- * Base64Url string with maximum length of 256 characters. Encoding strings as
177
- * Base64UrlString saves up to 25% in size compared to regular strings.
178
- */
179
- export const Base64Url256 = maxLength(256)(Base64Url);
180
- /**
181
- * A DbChange is a change to a table row. Together with a unique
182
- * {@link Timestamp}, it forms a {@link CrdtMessage}.
183
- */
184
- export const DbChange = object({
185
- table: Base64Url256,
186
- id: Id,
187
- values: record(Base64Url256, SqliteValue),
188
- });
189
- export const RangeType = {
190
- Fingerprint: 1,
191
- Skip: 0,
192
- Timestamps: 2,
270
+ SyncError: 4,
193
271
  };
194
- export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
195
- export const fingerprintSize = 12;
196
- /** A fingerprint of an empty range. */
197
- export const zeroFingerprint = new Uint8Array(fingerprintSize);
198
272
  /**
199
273
  * Creates a {@link ProtocolMessage} from CRDT messages.
200
274
  *
201
- * If the message size would exceed {@link maxProtocolMessageSize}, the protocol
202
- * ensures all messages will be sent in the next round(s) even over
275
+ * If the message size would exceed {@link defaultProtocolMessageMaxSize}, the
276
+ * protocol ensures all messages will be sent in the next round(s) even over
203
277
  * unidirectional and stateless transports.
204
278
  */
205
279
  export const createProtocolMessageFromCrdtMessages = (deps) => (owner, messages, maxSize) => {
206
280
  const buffer = createProtocolMessageBuffer(owner.id, {
207
- type: "initiator",
208
- totalMaxSize: maxSize ?? maxProtocolMessageSize,
281
+ messageType: MessageType.Request,
282
+ totalMaxSize: maxSize ?? defaultProtocolMessageMaxSize,
209
283
  writeKey: owner.writeKey,
210
284
  });
211
285
  let notAllMessagesSent = false;
212
286
  for (const message of messages) {
213
- const change = encodeAndEncryptDbChange(deps)(message.change, owner.encryptionKey);
287
+ const change = encodeAndEncryptDbChange(deps)(message, owner.encryptionKey);
214
288
  const encryptedCrdtMessage = { timestamp: message.timestamp, change };
215
289
  if (buffer.canAddMessage(encryptedCrdtMessage)) {
216
290
  buffer.addMessage(encryptedCrdtMessage);
@@ -229,14 +303,14 @@ export const createProtocolMessageFromCrdtMessages = (deps) => (owner, messages,
229
303
  *
230
304
  * The ideal approach would be to send three ranges (skip, fingerprint,
231
305
  * skip) where the fingerprint of unsent messages would act as narrow sync
232
- * probe. I think we can send {@link zeroFingerprint} which can be
233
- * interpreted as an indication that the other side should reply with
306
+ * probe. I think we can send `zeroFingerprint` which can be interpreted
307
+ * as an indication that the other side should reply with
234
308
  * {@link TimestampsRange}, so no need to restart syncing.
235
309
  *
236
310
  * For now, using a random fingerprint avoids extra complexity and is good
237
311
  * enough for this case.
238
312
  */
239
- const randomFingerprint = deps.createRandomBytes(fingerprintSize);
313
+ const randomFingerprint = deps.randomBytes.create(fingerprintSize);
240
314
  // There is always a space for Fingerprint with InfiniteUpperBound.
241
315
  buffer.addRange({
242
316
  type: RangeType.Fingerprint,
@@ -247,26 +321,25 @@ export const createProtocolMessageFromCrdtMessages = (deps) => (owner, messages,
247
321
  return buffer.unwrap();
248
322
  };
249
323
  /** Creates a {@link ProtocolMessage} for sync. */
250
- export const createProtocolMessageForSync = (deps) => (ownerId) => {
251
- const buffer = createProtocolMessageBuffer(ownerId, { type: "initiator" });
252
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
253
- const size = deps.storage.getSize(binaryOwnerId);
324
+ export const createProtocolMessageForSync = (deps) => (ownerId, subscriptionFlag) => {
325
+ const buffer = createProtocolMessageBuffer(ownerId, {
326
+ messageType: MessageType.Request,
327
+ subscriptionFlag: subscriptionFlag ?? SubscriptionFlags.None,
328
+ });
329
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
330
+ const size = deps.storage.getSize(ownerIdBytes);
254
331
  // Errors are handled by the storage.
255
332
  if (size == null)
256
333
  return null;
257
- splitRange(deps)(binaryOwnerId, 0, size, InfiniteUpperBound, buffer);
258
- return buffer.unwrap();
259
- };
260
- /** Creates a ProtocolMessage for {@link WriteKey} rotation. */
261
- export const createProtocolMessageForWriteKeyRotation = (ownerId, currentWriteKey, newWriteKey) => {
262
- const buffer = createProtocolMessageBuffer(ownerId, {
263
- type: "initiator",
264
- writeKey: [currentWriteKey, newWriteKey],
265
- });
334
+ splitRange(deps)(ownerIdBytes, NonNegativeInt.orThrow(0), size, InfiniteUpperBound, buffer);
266
335
  return buffer.unwrap();
267
336
  };
337
+ export const createProtocolMessageForUnsubscribe = (ownerId) => createProtocolMessageBuffer(ownerId, {
338
+ messageType: MessageType.Request,
339
+ subscriptionFlag: SubscriptionFlags.Unsubscribe,
340
+ }).unwrap();
268
341
  export const createProtocolMessageBuffer = (ownerId, options) => {
269
- const { totalMaxSize = maxProtocolMessageSize, rangesMaxSize = maxProtocolMessageRangesSize, version = protocolVersion, } = options;
342
+ const { totalMaxSize = defaultProtocolMessageMaxSize, rangesMaxSize = defaultProtocolMessageRangesMaxSize, version = protocolVersion, } = options;
270
343
  const buffers = {
271
344
  header: createBuffer(),
272
345
  messages: {
@@ -280,27 +353,25 @@ export const createProtocolMessageBuffer = (ownerId, options) => {
280
353
  },
281
354
  };
282
355
  encodeNonNegativeInt(buffers.header, version);
283
- buffers.header.extend(ownerIdToBinaryOwnerId(ownerId));
284
- if (options.type === "initiator") {
356
+ buffers.header.extend(ownerIdToOwnerIdBytes(ownerId));
357
+ buffers.header.extend([options.messageType]);
358
+ if (options.messageType === MessageType.Request) {
285
359
  if (!options.writeKey) {
286
- buffers.header.extend([WriteKeyMode.None]);
287
- }
288
- else if (!Array.isArray(options.writeKey)) {
289
- buffers.header.extend([WriteKeyMode.Single]);
290
- buffers.header.extend(options.writeKey);
360
+ buffers.header.extend([0]);
291
361
  }
292
362
  else {
293
- buffers.header.extend([WriteKeyMode.Rotation]);
294
- buffers.header.extend(options.writeKey[0]); // current
295
- buffers.header.extend(options.writeKey[1]); // new
363
+ buffers.header.extend([1]);
364
+ buffers.header.extend(options.writeKey);
296
365
  }
366
+ const subscriptionFlag = options.subscriptionFlag ?? SubscriptionFlags.None;
367
+ buffers.header.extend([subscriptionFlag]);
297
368
  }
298
- else {
369
+ else if (options.messageType === MessageType.Response) {
299
370
  buffers.header.extend([options.errorCode]);
300
371
  }
301
372
  let isLastRangeInfinite = false;
302
373
  const isWithinSizeLimits = () => getSize() <= totalMaxSize;
303
- const getSize = () => (getHeaderAndMessagesSize() + getRangesSize());
374
+ const getSize = () => PositiveInt.orThrow(getHeaderAndMessagesSize() + getRangesSize());
304
375
  const getHeaderAndMessagesSize = () => buffers.header.getLength() +
305
376
  buffers.messages.timestamps.getLength() +
306
377
  buffers.messages.dbChanges.getLength();
@@ -357,6 +428,7 @@ export const createProtocolMessageBuffer = (ownerId, options) => {
357
428
  : true));
358
429
  },
359
430
  addRange: (range) => {
431
+ assert(options.messageType !== MessageType.Broadcast, "Cannot add a range into broadcast message");
360
432
  assert(!isLastRangeInfinite, "Cannot add a range after an InfiniteUpperBound range");
361
433
  isLastRangeInfinite = range.upperBound === InfiniteUpperBound;
362
434
  /**
@@ -365,11 +437,11 @@ export const createProtocolMessageBuffer = (ownerId, options) => {
365
437
  * use SkipRange.
366
438
  */
367
439
  if (range.upperBound !== InfiniteUpperBound)
368
- buffers.ranges.timestamps.add(binaryTimestampToTimestamp(range.upperBound));
440
+ buffers.ranges.timestamps.add(timestampBytesToTimestamp(range.upperBound));
369
441
  else {
370
442
  buffers.ranges.timestamps.addInfinite();
371
443
  }
372
- encodeNonNegativeInt(buffers.ranges.types, range.type);
444
+ encodeNonNegativeInt(buffers.ranges.types, NonNegativeInt.orThrow(range.type));
373
445
  switch (range.type) {
374
446
  case RangeType.Skip:
375
447
  break;
@@ -400,7 +472,7 @@ export const createProtocolMessageBuffer = (ownerId, options) => {
400
472
  };
401
473
  };
402
474
  export const createTimestampsBuffer = () => {
403
- let count = 0;
475
+ let count = NonNegativeInt.orThrow(0);
404
476
  const countBuffer = createBuffer();
405
477
  const syncCount = () => {
406
478
  countBuffer.reset();
@@ -445,9 +517,9 @@ export const createTimestampsBuffer = () => {
445
517
  };
446
518
  const createRunLengthEncoder = (encodeValue) => {
447
519
  const buffer = createBuffer();
448
- let previousLength = 0;
520
+ let previousLength = NonNegativeInt.orThrow(0);
449
521
  let previousValue = null;
450
- let runLength = 0;
522
+ let runLength = NonNegativeInt.orThrow(0);
451
523
  return {
452
524
  add: (value) => {
453
525
  if (value === previousValue) {
@@ -456,7 +528,7 @@ const createRunLengthEncoder = (encodeValue) => {
456
528
  }
457
529
  else {
458
530
  previousValue = value;
459
- runLength = 1;
531
+ runLength = NonNegativeInt.orThrow(1);
460
532
  }
461
533
  previousLength = buffer.getLength();
462
534
  encodeValue(buffer, value);
@@ -466,155 +538,222 @@ const createRunLengthEncoder = (encodeValue) => {
466
538
  unwrap: () => buffer.unwrap(),
467
539
  };
468
540
  };
469
- export const applyProtocolMessageAsClient = (deps) => (inputMessage, { getWriteKey, version = protocolVersion, totalMaxSize, rangesMaxSize, } = {}) => tryDecodeProtocolData(inputMessage, (input) => {
470
- const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
471
- if (requestedVersion !== version) {
472
- return err({
473
- type: "ProtocolUnsupportedVersionError",
474
- unsupportedVersion: requestedVersion,
475
- isInitiator: version < requestedVersion,
476
- ownerId,
541
+ export const applyProtocolMessageAsClient = (deps) => async (inputMessage, options = {}) => {
542
+ try {
543
+ const input = createBuffer(inputMessage);
544
+ const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
545
+ const version = options.version ?? protocolVersion;
546
+ if (requestedVersion !== version) {
547
+ return err({
548
+ type: "ProtocolVersionError",
549
+ version: requestedVersion,
550
+ isInitiator: version < requestedVersion,
551
+ ownerId,
552
+ });
553
+ }
554
+ const messageType = input.shift();
555
+ assert(messageType === MessageType.Response ||
556
+ messageType === MessageType.Broadcast, "Invalid MessageType");
557
+ if (messageType === MessageType.Response) {
558
+ const errorCode = input.shift();
559
+ if (errorCode !== ProtocolErrorCode.NoError) {
560
+ switch (errorCode) {
561
+ case ProtocolErrorCode.WriteKeyError:
562
+ return err({
563
+ type: "ProtocolWriteKeyError",
564
+ ownerId,
565
+ });
566
+ case ProtocolErrorCode.WriteError:
567
+ return err({
568
+ type: "ProtocolWriteError",
569
+ ownerId,
570
+ });
571
+ case ProtocolErrorCode.QuotaError:
572
+ return err({
573
+ type: "ProtocolQuotaError",
574
+ ownerId,
575
+ });
576
+ case ProtocolErrorCode.SyncError:
577
+ return err({
578
+ type: "ProtocolSyncError",
579
+ ownerId,
580
+ });
581
+ default:
582
+ throw new ProtocolDecodeError(`Invalid ProtocolErrorCode: ${errorCode}`);
583
+ }
584
+ }
585
+ }
586
+ const messages = decodeMessages(input);
587
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
588
+ if (isNonEmptyReadonlyArray(messages)) {
589
+ const writeResult = await deps.storage.writeMessages(ownerIdBytes, messages);
590
+ // Errors are handled by the Storage. Here we just stop syncing.
591
+ if (!writeResult.ok)
592
+ return ok({ type: "no-response" });
593
+ }
594
+ // Now: No writeKey, no sync.
595
+ // TODO: Allow to sync SharedReadonlyOwner
596
+ // Without local changes, writeKey will not be required.
597
+ // With local changes, writeKey will be required and if not provided,
598
+ // the sync will stop.
599
+ const writeKey = options.getWriteKey?.(ownerId);
600
+ if (writeKey == null) {
601
+ return ok({ type: "no-response" });
602
+ }
603
+ if (messageType === MessageType.Broadcast) {
604
+ return ok({ type: "broadcast" });
605
+ }
606
+ const ranges = decodeRanges(input);
607
+ if (!isNonEmptyReadonlyArray(ranges)) {
608
+ return ok({ type: "no-response" });
609
+ }
610
+ const output = createProtocolMessageBuffer(ownerId, {
611
+ messageType: MessageType.Request,
612
+ writeKey,
613
+ rangesMaxSize: options.rangesMaxSize,
477
614
  });
478
- }
479
- const errorCode = input.shift();
480
- if (errorCode !== ProtocolErrorCode.NoError) {
481
- switch (errorCode) {
482
- case ProtocolErrorCode.WriteKeyError:
483
- return err({
484
- type: "ProtocolWriteKeyError",
485
- ownerId,
486
- });
487
- case ProtocolErrorCode.WriteError:
488
- return err({
489
- type: "ProtocolWriteError",
490
- ownerId,
491
- });
492
- case ProtocolErrorCode.SyncError:
493
- return err({
494
- type: "ProtocolSyncError",
495
- ownerId,
496
- });
497
- default:
498
- throw new ProtocolDecodeError(`Invalid ProtocolErrorCode: ${errorCode}`);
615
+ const syncResult = sync(deps)(ranges, output, ownerIdBytes);
616
+ // Client sync error (handled via Storage) or no changes.
617
+ if (!syncResult.ok || !syncResult.value) {
618
+ return ok({ type: "no-response" });
499
619
  }
620
+ return ok({ type: "response", message: output.unwrap() });
500
621
  }
501
- const messages = decodeMessages(input);
502
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
503
- if (isNonEmptyReadonlyArray(messages) &&
504
- !deps.storage.writeMessages(binaryOwnerId, messages)) {
505
- return ok(null);
506
- }
507
- if (!getWriteKey)
508
- return ok(null);
509
- const writeKey = getWriteKey(ownerId);
510
- if (writeKey == null)
511
- return ok(null);
512
- const output = createProtocolMessageBuffer(ownerId, {
513
- type: "initiator",
514
- writeKey,
515
- totalMaxSize,
516
- rangesMaxSize,
517
- });
518
- return sync(deps)("initiator", input, output, binaryOwnerId);
519
- });
520
- export const applyProtocolMessageAsRelay = (deps) => (inputMessage, { subscribe, broadcast, totalMaxSize, rangesMaxSize, } = {},
521
- /** For testing purposes only; should not be used in production. */
522
- version = protocolVersion) => tryDecodeProtocolData(inputMessage, (input) => {
523
- const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
524
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
525
- if (requestedVersion !== version) {
526
- // Non-initiator responds with its version and ownerId.
527
- const output = createBuffer();
528
- encodeNonNegativeInt(output, version);
529
- output.extend(binaryOwnerId);
530
- return ok(output.unwrap());
622
+ catch (error) {
623
+ return err({
624
+ type: "ProtocolInvalidDataError",
625
+ data: inputMessage,
626
+ error,
627
+ });
531
628
  }
532
- subscribe?.(ownerId);
533
- const writeKeyMode = input.shift();
534
- let writeKey;
535
- let newWriteKey;
536
- if (writeKeyMode !== WriteKeyMode.None) {
537
- writeKey = input.shiftN(writeKeyLength);
538
- switch (writeKeyMode) {
539
- case WriteKeyMode.Single:
629
+ };
630
+ export const applyProtocolMessageAsRelay = (deps) => async (inputMessage, options = {},
631
+ /** For tests only. */
632
+ version = protocolVersion) => {
633
+ try {
634
+ const input = createBuffer(inputMessage);
635
+ const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
636
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
637
+ if (requestedVersion !== version) {
638
+ // Non-initiator responds with its version and ownerId.
639
+ const output = createBuffer();
640
+ encodeNonNegativeInt(output, version);
641
+ output.extend(ownerIdBytes);
642
+ return ok({
643
+ type: "response",
644
+ message: output.unwrap(),
645
+ });
646
+ }
647
+ const messageType = input.shift();
648
+ assert(messageType === MessageType.Request, "Invalid MessageType");
649
+ const hasWriteKey = input.shift();
650
+ let writeKey;
651
+ if (hasWriteKey === 1) {
652
+ writeKey = input.shiftN(ownerWriteKeyLength);
653
+ }
654
+ const subscriptionFlag = input.shift();
655
+ switch (subscriptionFlag) {
656
+ case SubscriptionFlags.Subscribe:
657
+ options.subscribe?.(ownerId);
540
658
  break;
541
- case WriteKeyMode.Rotation:
542
- newWriteKey = input.shiftN(writeKeyLength);
659
+ case SubscriptionFlags.Unsubscribe:
660
+ options.unsubscribe?.(ownerId);
661
+ break;
662
+ case SubscriptionFlags.None:
543
663
  break;
544
- default:
545
- throw new ProtocolDecodeError(`Invalid WriteKeyMode: ${writeKeyMode}`);
546
- }
547
- }
548
- if (writeKey) {
549
- const isValid = deps.storage.validateWriteKey(binaryOwnerId, writeKey);
550
- if (!isValid) {
551
- return ok(createProtocolMessageBuffer(ownerId, {
552
- type: "non-initiator",
553
- errorCode: ProtocolErrorCode.WriteKeyError,
554
- }).unwrap());
555
664
  }
556
- if (newWriteKey) {
557
- const rotationSuccess = deps.storage.setWriteKey(binaryOwnerId, newWriteKey);
558
- if (!rotationSuccess) {
559
- return ok(createProtocolMessageBuffer(ownerId, {
560
- type: "non-initiator",
561
- errorCode: ProtocolErrorCode.WriteError,
562
- }).unwrap());
665
+ if (writeKey) {
666
+ const isValid = deps.storage.validateWriteKey(ownerIdBytes, writeKey);
667
+ if (!isValid) {
668
+ return ok({
669
+ type: "response",
670
+ message: createProtocolMessageBuffer(ownerId, {
671
+ messageType: MessageType.Response,
672
+ errorCode: ProtocolErrorCode.WriteKeyError,
673
+ }).unwrap(),
674
+ });
563
675
  }
564
676
  }
565
- }
566
- const messages = decodeMessages(input);
567
- if (isNonEmptyReadonlyArray(messages)) {
568
- if (!writeKey)
569
- return ok(createProtocolMessageBuffer(ownerId, {
570
- type: "non-initiator",
571
- errorCode: ProtocolErrorCode.WriteKeyError,
572
- }).unwrap());
573
- // Only broadcast if there's no ranges.
574
- if (broadcast && input.getLength() === 0) {
575
- const broadcastBuffer = createProtocolMessageBuffer(ownerId, {
576
- type: "non-initiator",
577
- errorCode: ProtocolErrorCode.NoError,
578
- totalMaxSize,
579
- rangesMaxSize,
580
- version,
581
- });
582
- for (const message of messages) {
583
- broadcastBuffer.addMessage(message);
677
+ const messages = decodeMessages(input);
678
+ if (isNonEmptyReadonlyArray(messages)) {
679
+ if (!writeKey) {
680
+ return ok({
681
+ type: "response",
682
+ message: createProtocolMessageBuffer(ownerId, {
683
+ messageType: MessageType.Response,
684
+ errorCode: ProtocolErrorCode.WriteKeyError,
685
+ }).unwrap(),
686
+ });
687
+ }
688
+ const writeResult = await deps.storage.writeMessages(ownerIdBytes, messages);
689
+ if (!writeResult.ok) {
690
+ const errorCode = writeResult.error.type === "StorageWriteError"
691
+ ? ProtocolErrorCode.WriteError
692
+ : ProtocolErrorCode.QuotaError;
693
+ const message = createProtocolMessageBuffer(ownerId, {
694
+ messageType: MessageType.Response,
695
+ errorCode,
696
+ }).unwrap();
697
+ return ok({ type: "response", message });
698
+ }
699
+ /**
700
+ * Broadcast messages to all subscribed owners for real-time
701
+ * synchronization between clients.
702
+ *
703
+ * Messages are only broadcasted after successful write to ensure
704
+ * devices that can still sync aren't affected by quota errors, and to
705
+ * prevent using a half-working relay service (broadcasting without
706
+ * persistence).
707
+ *
708
+ * When a relay's database is deleted or clients migrate to a new relay
709
+ * (without data migration), clients will sync their data to the relay,
710
+ * and the relay will broadcast those messages to other connected
711
+ * clients. Those clients may receive messages they already have, but
712
+ * this is safe because Evolu sync is idempotent. As the relay becomes
713
+ * more synchronized with clients over time, fewer duplicate messages
714
+ * will be broadcasted.
715
+ */
716
+ if (options.broadcast) {
717
+ const broadcastBuffer = createProtocolMessageBuffer(ownerId, {
718
+ messageType: MessageType.Broadcast,
719
+ totalMaxSize: options.totalMaxSize,
720
+ rangesMaxSize: options.rangesMaxSize,
721
+ version,
722
+ });
723
+ for (const message of messages) {
724
+ broadcastBuffer.addMessage(message);
725
+ }
726
+ options.broadcast(ownerId, broadcastBuffer.unwrap());
584
727
  }
585
- broadcast(ownerId, broadcastBuffer.unwrap());
586
728
  }
587
- if (!deps.storage.writeMessages(binaryOwnerId, messages))
588
- return ok(createProtocolMessageBuffer(ownerId, {
589
- type: "non-initiator",
590
- errorCode: ProtocolErrorCode.WriteError,
591
- }).unwrap());
592
- }
593
- const output = createProtocolMessageBuffer(ownerId, {
594
- type: "non-initiator",
595
- errorCode: ProtocolErrorCode.NoError,
596
- totalMaxSize,
597
- rangesMaxSize,
598
- });
599
- return sync(deps)("non-initiator", input, output, binaryOwnerId);
600
- });
601
- /**
602
- * Wraps Evolu Protocol decoding functions, which use exceptions instead of
603
- * {@link Result} to provide stack traces for debugging and reduce allocation
604
- * overhead in success cases.
605
- */
606
- const tryDecodeProtocolData = (data, callback) => {
607
- try {
608
- return callback(createBuffer(data));
729
+ const ranges = decodeRanges(input);
730
+ const output = createProtocolMessageBuffer(ownerId, {
731
+ messageType: MessageType.Response,
732
+ errorCode: ProtocolErrorCode.NoError,
733
+ totalMaxSize: options.totalMaxSize,
734
+ rangesMaxSize: options.rangesMaxSize,
735
+ });
736
+ // Non-initiators always respond to provide sync completion feedback,
737
+ // even when there's nothing to sync.
738
+ if (!isNonEmptyReadonlyArray(ranges)) {
739
+ return ok({ type: "response", message: output.unwrap() });
740
+ }
741
+ const syncResult = sync(deps)(ranges, output, ownerIdBytes);
742
+ const message = syncResult.ok
743
+ ? output.unwrap()
744
+ : createProtocolMessageBuffer(ownerId, {
745
+ messageType: MessageType.Response,
746
+ errorCode: syncResult.error,
747
+ }).unwrap();
748
+ // Non-initiators always respond to provide sync completion feedback,
749
+ return ok({ type: "response", message });
609
750
  }
610
751
  catch (error) {
611
- if (error instanceof ProtocolDecodeError || error instanceof BufferError)
612
- return err({
613
- type: "ProtocolInvalidDataError",
614
- data,
615
- error,
616
- });
617
- throw error;
752
+ return err({
753
+ type: "ProtocolInvalidDataError",
754
+ data: inputMessage,
755
+ error,
756
+ });
618
757
  }
619
758
  };
620
759
  const decodeVersionAndOwner = (input) => {
@@ -623,7 +762,7 @@ const decodeVersionAndOwner = (input) => {
623
762
  // to enable version negotiation and owner identification before any other
624
763
  // processing occurs.
625
764
  const version = decodeNonNegativeInt(input);
626
- const ownerId = decodeOwnerId(input);
765
+ const ownerId = decodeId(input);
627
766
  return [version, ownerId];
628
767
  };
629
768
  /**
@@ -647,30 +786,13 @@ const decodeMessages = (buffer) => {
647
786
  }
648
787
  return messages;
649
788
  };
650
- const sync = (deps) => (role, input, output, ownerId) => {
651
- const ranges = decodeRanges(input);
652
- if (!isNonEmptyReadonlyArray(ranges)) {
653
- // Nothing to sync.
654
- return ok(null);
655
- }
656
- const binaryOwnerId = binaryOwnerIdToOwnerId(ownerId);
789
+ const sync = (deps) => (ranges, output, ownerIdBytes) => {
657
790
  const outputInitialSize = output.getSize();
658
- const syncFail = () => {
659
- // Only the relay (non-initiator) reports sync errors, not the client (initiator).
660
- if (role === "initiator") {
661
- return ok(null);
662
- }
663
- const message = createProtocolMessageBuffer(binaryOwnerId, {
664
- type: "non-initiator",
665
- errorCode: ProtocolErrorCode.SyncError,
666
- });
667
- return ok(message.unwrap());
668
- };
669
- const storageSize = deps.storage.getSize(ownerId);
791
+ const storageSize = deps.storage.getSize(ownerIdBytes);
670
792
  if (storageSize == null)
671
- return syncFail();
793
+ return err(ProtocolErrorCode.SyncError);
672
794
  let prevUpperBound = null;
673
- let prevIndex = 0;
795
+ let prevIndex = NonNegativeInt.orThrow(0);
674
796
  let skip = false;
675
797
  let nonSkipRangeAdded = false;
676
798
  const skipRange = (range) => {
@@ -700,7 +822,7 @@ const sync = (deps) => (role, input, output, ownerId) => {
700
822
  };
701
823
  // When we don't have a space...
702
824
  const addFingerprintForRemainingRange = (begin) => {
703
- const fingerprint = deps.storage.fingerprint(ownerId, begin, storageSize);
825
+ const fingerprint = deps.storage.fingerprint(ownerIdBytes, begin, storageSize);
704
826
  if (!fingerprint)
705
827
  return false;
706
828
  // There is always a space for a ramaining range.
@@ -714,30 +836,30 @@ const sync = (deps) => (role, input, output, ownerId) => {
714
836
  for (const range of ranges) {
715
837
  const currentUpperBound = range.upperBound;
716
838
  const lower = prevIndex;
717
- let upper = deps.storage.findLowerBound(ownerId, prevIndex, storageSize, currentUpperBound);
839
+ let upper = deps.storage.findLowerBound(ownerIdBytes, prevIndex, storageSize, currentUpperBound);
718
840
  if (upper == null)
719
- return syncFail();
841
+ return err(ProtocolErrorCode.SyncError);
720
842
  switch (range.type) {
721
843
  case RangeType.Skip: {
722
844
  skipRange(range);
723
845
  break;
724
846
  }
725
847
  case RangeType.Fingerprint: {
726
- const ourFingerprint = deps.storage.fingerprint(ownerId, lower, upper);
848
+ const ourFingerprint = deps.storage.fingerprint(ownerIdBytes, lower, upper);
727
849
  if (ourFingerprint == null)
728
- return syncFail();
850
+ return err(ProtocolErrorCode.SyncError);
729
851
  if (eqArrayNumber(range.fingerprint, ourFingerprint)) {
730
852
  skipRange(range);
731
853
  }
732
854
  else {
733
855
  if (output.canSplitRange()) {
734
856
  coalesceSkipsBeforeAdd();
735
- splitRange(deps)(ownerId, lower, upper, currentUpperBound, output);
857
+ splitRange(deps)(ownerIdBytes, lower, upper, currentUpperBound, output);
736
858
  }
737
859
  else {
738
- if (!addFingerprintForRemainingRange(upper))
739
- return syncFail();
740
- return ok(output.unwrap());
860
+ return addFingerprintForRemainingRange(upper)
861
+ ? ok(true)
862
+ : err(ProtocolErrorCode.SyncError);
741
863
  }
742
864
  }
743
865
  break;
@@ -746,19 +868,19 @@ const sync = (deps) => (role, input, output, ownerId) => {
746
868
  let endBound = currentUpperBound;
747
869
  const timestampsWeNeed = new Map(range.timestamps.map((t) => [t.join(), true]));
748
870
  const ourTimestamps = createTimestampsBuffer();
749
- let storageError = false;
871
+ let cantReadDbChange = false;
750
872
  let exceeded = false;
751
- deps.storage.iterate(ownerId, lower, upper, (timestamp, index) => {
873
+ deps.storage.iterate(ownerIdBytes, lower, upper, (timestamp, index) => {
752
874
  const timestampString = timestamp.join();
753
- const timestampBinary = binaryTimestampToTimestamp(timestamp);
875
+ const timestampBinary = timestampBytesToTimestamp(timestamp);
754
876
  let message = null;
755
877
  if (timestampsWeNeed.has(timestampString)) {
756
878
  timestampsWeNeed.delete(timestampString);
757
879
  }
758
880
  else {
759
- const dbChange = deps.storage.readDbChange(ownerId, timestamp);
881
+ const dbChange = deps.storage.readDbChange(ownerIdBytes, timestamp);
760
882
  if (dbChange == null) {
761
- storageError = true;
883
+ cantReadDbChange = true;
762
884
  return false;
763
885
  }
764
886
  message = {
@@ -777,8 +899,8 @@ const sync = (deps) => (role, input, output, ownerId) => {
777
899
  output.addMessage(message);
778
900
  return true;
779
901
  });
780
- if (storageError) {
781
- return syncFail();
902
+ if (cantReadDbChange) {
903
+ return err(ProtocolErrorCode.SyncError);
782
904
  }
783
905
  const addRange = () => {
784
906
  coalesceSkipsBeforeAdd();
@@ -791,9 +913,9 @@ const sync = (deps) => (role, input, output, ownerId) => {
791
913
  if (exceeded) {
792
914
  addRange();
793
915
  if (!addFingerprintForRemainingRange(upper)) {
794
- return syncFail();
916
+ return err(ProtocolErrorCode.SyncError);
795
917
  }
796
- return ok(output.unwrap());
918
+ return ok(true);
797
919
  }
798
920
  // If we need something, we have to respond with our timestamps.
799
921
  if (timestampsWeNeed.size > 0) {
@@ -810,10 +932,10 @@ const sync = (deps) => (role, input, output, ownerId) => {
810
932
  }
811
933
  // If all ranges were skipped, there are no changes and sync is complete.
812
934
  const hasChange = output.getSize() > outputInitialSize;
813
- return ok(hasChange ? output.unwrap() : null);
935
+ return ok(hasChange);
814
936
  };
815
937
  const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
816
- const itemCount = (upper - lower);
938
+ const itemCount = NonNegativeInt.orThrow(upper - lower);
817
939
  const buckets = computeBalancedBuckets(itemCount);
818
940
  if (!buckets.ok) {
819
941
  const range = {
@@ -821,8 +943,8 @@ const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
821
943
  upperBound,
822
944
  timestamps: createTimestampsBuffer(),
823
945
  };
824
- deps.storage.iterate(ownerId, 0, itemCount, (timestamp) => {
825
- range.timestamps.add(binaryTimestampToTimestamp(timestamp));
946
+ deps.storage.iterate(ownerId, NonNegativeInt.orThrow(0), itemCount, (timestamp) => {
947
+ range.timestamps.add(timestampBytesToTimestamp(timestamp));
826
948
  return true;
827
949
  });
828
950
  buffer.addRange(range);
@@ -831,7 +953,10 @@ const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
831
953
  // Check Storage.ts `fingerprint` and `fingerprintRanges` docs.
832
954
  const fingerprintRangesBuckets = lower === 0
833
955
  ? buckets.value
834
- : [lower, ...buckets.value.map((b) => (b + lower))];
956
+ : [
957
+ lower,
958
+ ...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)),
959
+ ];
835
960
  const fingerprintRanges = deps.storage.fingerprintRanges(ownerId, fingerprintRangesBuckets, upperBound);
836
961
  // Errors are handled by the storage.
837
962
  if (fingerprintRanges == null)
@@ -847,7 +972,7 @@ const decodeRanges = (buffer) => {
847
972
  const rangesCount = decodeNonNegativeInt(buffer);
848
973
  if (rangesCount === 0)
849
974
  return [];
850
- const timestampsCount = (rangesCount - 1);
975
+ const timestampsCount = NonNegativeInt.orThrow(rangesCount - 1);
851
976
  const timestamps = decodeTimestamps(buffer, timestampsCount);
852
977
  const rangeTypes = [];
853
978
  for (let i = 0; i < rangesCount; i++) {
@@ -865,7 +990,7 @@ const decodeRanges = (buffer) => {
865
990
  const ranges = [];
866
991
  for (let i = 0; i < rangesCount; i++) {
867
992
  const upperBound = i < timestampsCount
868
- ? timestampToBinaryTimestamp(timestamps[i])
993
+ ? timestampToTimestampBytes(timestamps[i])
869
994
  : InfiniteUpperBound;
870
995
  const rangeType = rangeTypes[i];
871
996
  switch (rangeType) {
@@ -882,7 +1007,7 @@ const decodeRanges = (buffer) => {
882
1007
  break;
883
1008
  }
884
1009
  case RangeType.Timestamps: {
885
- const timestamps = decodeTimestamps(buffer).map(timestampToBinaryTimestamp);
1010
+ const timestamps = decodeTimestamps(buffer).map(timestampToTimestampBytes);
886
1011
  ranges.push({
887
1012
  type: RangeType.Timestamps,
888
1013
  upperBound,
@@ -903,7 +1028,7 @@ const decodeTimestamps = (buffer, length) => {
903
1028
  const deltaMillis = decodeNonNegativeInt(buffer);
904
1029
  const millis = Millis.from(previousMillis + deltaMillis);
905
1030
  if (!millis.ok)
906
- throw new Error(millis.error.type);
1031
+ throw new ProtocolDecodeError(millis.error.type);
907
1032
  millises.push(millis.value);
908
1033
  previousMillis = millis.value;
909
1034
  }
@@ -912,7 +1037,7 @@ const decodeTimestamps = (buffer, length) => {
912
1037
  while (counterIndex < length) {
913
1038
  const counter = Counter.from(decodeNonNegativeInt(buffer));
914
1039
  if (!counter.ok)
915
- throw new Error(counter.error.type);
1040
+ throw new ProtocolDecodeError(counter.error.type);
916
1041
  const runLength = decodeNonNegativeInt(buffer);
917
1042
  for (let i = 0; i < runLength; i++) {
918
1043
  counters.push(counter.value);
@@ -939,79 +1064,21 @@ const decodeTimestamps = (buffer, length) => {
939
1064
  }
940
1065
  return timestamps;
941
1066
  };
942
- export const binaryIdLength = 16;
943
- export const idToBinaryId = (id) => base64Url256ToBytes(id);
944
- export const binaryIdToId = (binaryId) => decodeId(createBuffer(binaryId));
945
- export const ownerIdToBinaryOwnerId = (ownerId) => base64Url256ToBytes(ownerId);
946
- export const binaryOwnerIdToOwnerId = (binaryOwnerId) => decodeOwnerId(createBuffer(binaryOwnerId));
947
- /**
948
- * Alphabet used for Base64Url encoding. This is copied from the `nanoid`
949
- * library to avoid dependency on a specific version of `nanoid`.
950
- */
951
- const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
952
- /**
953
- * Converts a Base64Url string to a Uint8Array for binary storage. This encoding
954
- * is more space-efficient than UTF-8 for Base64Url strings.
955
- */
956
- export const base64Url256ToBytes = (string) => {
957
- const totalBits = string.length * 6; // 6 bits per character
958
- const byteLength = Math.ceil(totalBits / 8);
959
- const value = new globalThis.Uint8Array(byteLength);
960
- let bitBuffer = 0;
961
- let bitsInBuffer = 0;
962
- let byteIndex = 0;
963
- for (const char of string) {
964
- const charValue = urlAlphabet.indexOf(char);
965
- bitBuffer = (bitBuffer << 6) | charValue;
966
- bitsInBuffer += 6;
967
- while (bitsInBuffer >= 8) {
968
- bitsInBuffer -= 8;
969
- value[byteIndex++] = (bitBuffer >> bitsInBuffer) & 0xff;
970
- }
971
- }
972
- if (bitsInBuffer > 0 && byteIndex < byteLength) {
973
- value[byteIndex] = (bitBuffer << (8 - bitsInBuffer)) & 0xff;
974
- }
975
- return value;
976
- };
977
- export const decodeBase64Url256 = (buffer, stringLength) => {
978
- const bytes = buffer.shiftN(Math.ceil((stringLength * 6) / 8));
979
- let bitBuffer = 0;
980
- let bitsInBuffer = 0;
981
- let string = "";
982
- for (const byte of bytes) {
983
- bitBuffer = (bitBuffer << 8) | byte;
984
- bitsInBuffer += 8;
985
- while (bitsInBuffer >= 6) {
986
- bitsInBuffer -= 6;
987
- if (string.length < stringLength) {
988
- const charValue = (bitBuffer >> bitsInBuffer) & 0x3f;
989
- if (charValue < 0 || charValue >= urlAlphabet.length) {
990
- throw new ProtocolDecodeError("invalid charValue");
991
- }
992
- string += urlAlphabet[charValue];
993
- }
994
- }
995
- }
996
- const result = Base64Url256.from(string);
997
- if (!result.ok)
998
- throw new ProtocolDecodeError(result.error.type);
999
- return result.value;
1067
+ const decodeId = (buffer) => {
1068
+ const bytes = buffer.shiftN(idBytesTypeValueLength);
1069
+ return idBytesToId(bytes);
1000
1070
  };
1001
- const decodeId = (buffer) => decodeBase64Url256(buffer, idTypeValueLength);
1002
- /** Not all 16 bytes are valid {@link OwnerId}. */
1003
- const decodeOwnerId = (buffer) => decodeId(buffer);
1004
1071
  /**
1005
1072
  * Evolu uses MessagePack to handle all number variants except for
1006
1073
  * NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
1007
1074
  */
1008
1075
  export const encodeNumber = (buffer, number) => {
1009
- buffer.extend(pack(number));
1076
+ buffer.extend(packr.pack(number));
1010
1077
  };
1011
1078
  export const decodeNumber = (buffer) => {
1012
1079
  let number;
1013
1080
  let end;
1014
- unpackMultiple(buffer.unwrap(), (n, _, e) => {
1081
+ packr.unpackMultiple(buffer.unwrap(), (n, _, e) => {
1015
1082
  number = n;
1016
1083
  end = e;
1017
1084
  return false;
@@ -1025,29 +1092,73 @@ export const decodeNumber = (buffer) => {
1025
1092
  buffer.shiftN(endResult.value);
1026
1093
  return numberResult.value;
1027
1094
  };
1028
- export const binaryTimestampToFingerprint = (timestamp) => {
1029
- const hash = sha256(timestamp).slice(0, fingerprintSize);
1030
- return hash;
1095
+ /**
1096
+ * Encodes an array of boolean flags into a single byte.
1097
+ *
1098
+ * Each element in the array corresponds to a bit (0-7). Array can have 0-8
1099
+ * elements.
1100
+ *
1101
+ * ### Example
1102
+ *
1103
+ * ```ts
1104
+ * encodeFlags(buffer, [true, false, true]); // Encodes bits 0, 1, 2
1105
+ * ```
1106
+ */
1107
+ export const encodeFlags = (buffer, flags) => {
1108
+ let byte = 0;
1109
+ for (let i = 0; i < flags.length && i < 8; i++) {
1110
+ if (flags[i]) {
1111
+ byte |= 1 << i;
1112
+ }
1113
+ }
1114
+ buffer.extend([byte]);
1115
+ };
1116
+ /**
1117
+ * Decodes a byte into an array of boolean flags.
1118
+ *
1119
+ * ### Example
1120
+ *
1121
+ * ```ts
1122
+ * const flags = decodeFlags(buffer, 3); // Decode 3 flags
1123
+ * ```
1124
+ */
1125
+ export const decodeFlags = (buffer, count) => {
1126
+ const byte = buffer.shift();
1127
+ const flags = [];
1128
+ for (let i = 0; i < count && i < 8; i++) {
1129
+ flags.push((byte & (1 << i)) !== 0);
1130
+ }
1131
+ return flags;
1031
1132
  };
1032
1133
  /**
1033
1134
  * Encodes and encrypts a {@link DbChange} using the provided owner's encryption
1034
1135
  * key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
1136
+ *
1137
+ * The format includes the protocol version for backward compatibility and the
1138
+ * timestamp for tamper-proof verification that the timestamp matches the change
1139
+ * data.
1035
1140
  */
1036
- export const encodeAndEncryptDbChange = (deps) => (change, key) => {
1141
+ export const encodeAndEncryptDbChange = (deps) => (message, key) => {
1037
1142
  const buffer = createBuffer();
1038
- encodeBase64Url256(buffer, change.table);
1039
- buffer.extend(idToBinaryId(change.id));
1040
- const entries = objectToEntries(change.values).map(([column, value]) => {
1041
- return [column, value];
1042
- });
1143
+ encodeNonNegativeInt(buffer, protocolVersion);
1144
+ // Encode the timestamp to prevent tampering (e.g., a malicious relay
1145
+ // assigning this EncryptedDbChange to a different EncryptedCrdtMessage)
1146
+ buffer.extend(timestampToTimestampBytes(message.timestamp));
1147
+ encodeFlags(buffer, [
1148
+ message.change.isInsert,
1149
+ message.change.isDelete != null,
1150
+ message.change.isDelete ?? false,
1151
+ ]);
1152
+ encodeString(buffer, message.change.table);
1153
+ buffer.extend(idToIdBytes(message.change.id));
1154
+ const entries = objectToEntries(message.change.values);
1043
1155
  encodeLength(buffer, entries);
1044
1156
  for (const [column, value] of entries) {
1045
- encodeBase64Url256(buffer, column);
1157
+ encodeString(buffer, column);
1046
1158
  encodeSqliteValue(buffer, value);
1047
1159
  }
1048
- const paddingLength = padmePaddingLength(buffer.getLength());
1049
- // Add zero bytes as PADMÉ padding - these will be ignored during decoding.
1050
- buffer.extend(new Uint8Array(paddingLength));
1160
+ // Add PADMÉ padding (ignored during decoding)
1161
+ buffer.extend(createPadmePadding(buffer.getLength()));
1051
1162
  const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(buffer.unwrap(), key);
1052
1163
  buffer.reset();
1053
1164
  buffer.extend(nonce);
@@ -1056,30 +1167,57 @@ export const encodeAndEncryptDbChange = (deps) => (change, key) => {
1056
1167
  return buffer.unwrap();
1057
1168
  };
1058
1169
  /**
1059
- * Decrypts and decodes an {@link EncryptedDbChange} using the provided owner's
1060
- * encryption key.
1170
+ * Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
1171
+ * owner's encryption key. Verifies that the embedded timestamp matches the
1172
+ * expected timestamp to ensure message integrity.
1061
1173
  */
1062
- export const decryptAndDecodeDbChange = (deps) => (change, key) => tryDecodeProtocolData(change, (buffer) => {
1063
- const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1064
- const ciphertextLength = decodeLength(buffer);
1065
- const ciphertext = buffer.shiftN(ciphertextLength);
1066
- const plaintextBytes = deps.symmetricCrypto.decrypt(ciphertext, key, nonce);
1067
- if (!plaintextBytes.ok)
1068
- return plaintextBytes;
1069
- buffer.reset();
1070
- buffer.extend(plaintextBytes.value);
1071
- const table = decodeBase64Url256WithLength(buffer);
1072
- const id = decodeId(buffer);
1073
- const length = decodeLength(buffer);
1074
- const values = Object.create(null);
1075
- for (let i = 0; i < length; i++) {
1076
- const column = decodeBase64Url256WithLength(buffer);
1077
- const value = decodeSqliteValue(buffer);
1078
- values[column] = value;
1174
+ export const decryptAndDecodeDbChange = (deps) => (message, key) => {
1175
+ try {
1176
+ const buffer = createBuffer(message.change);
1177
+ const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
1178
+ const ciphertext = buffer.shiftN(decodeLength(buffer));
1179
+ const plaintextBytes = deps.symmetricCrypto.decrypt(ciphertext, key, nonce);
1180
+ if (!plaintextBytes.ok)
1181
+ return plaintextBytes;
1182
+ buffer.reset();
1183
+ buffer.extend(plaintextBytes.value);
1184
+ // Decode version (for future compatibility, not need yet)
1185
+ decodeNonNegativeInt(buffer);
1186
+ const timestamp = timestampBytesToTimestamp(buffer.shiftN(timestampBytesLength));
1187
+ if (!eqTimestamp(timestamp, message.timestamp)) {
1188
+ return err({
1189
+ type: "ProtocolTimestampMismatchError",
1190
+ expected: message.timestamp,
1191
+ timestamp,
1192
+ });
1193
+ }
1194
+ const flags = decodeFlags(buffer, PositiveInt.orThrow(3));
1195
+ const table = decodeString(buffer);
1196
+ const id = decodeId(buffer);
1197
+ const length = decodeLength(buffer);
1198
+ const values = Object.create(null);
1199
+ for (let i = 0; i < length; i++) {
1200
+ const column = decodeString(buffer);
1201
+ const value = decodeSqliteValue(buffer);
1202
+ values[column] = value;
1203
+ }
1204
+ const dbChange = DbChange.orThrow({
1205
+ table,
1206
+ id,
1207
+ values,
1208
+ isInsert: flags[0],
1209
+ isDelete: flags[1] ? flags[2] : null,
1210
+ });
1211
+ return ok(dbChange);
1212
+ }
1213
+ catch (error) {
1214
+ return err({
1215
+ type: "ProtocolInvalidDataError",
1216
+ data: message.change,
1217
+ error,
1218
+ });
1079
1219
  }
1080
- const dbChange = { table, id, values };
1081
- return ok(dbChange);
1082
- });
1220
+ };
1083
1221
  /**
1084
1222
  * Encodes a non-negative integer into a variable-length integer format. It's
1085
1223
  * more efficient than encoding via {@link encodeNumber}.
@@ -1126,7 +1264,7 @@ export const decodeNonNegativeInt = (buffer) => {
1126
1264
  return int.value;
1127
1265
  };
1128
1266
  export const encodeLength = (buffer, value) => {
1129
- encodeNonNegativeInt(buffer, value.length);
1267
+ encodeNonNegativeInt(buffer, NonNegativeInt.orThrow(value.length));
1130
1268
  };
1131
1269
  export const decodeLength = decodeNonNegativeInt;
1132
1270
  export const encodeString = (buffer, value) => {
@@ -1143,37 +1281,31 @@ export const encodeNodeId = (buffer, nodeId) => {
1143
1281
  buffer.extend(hexToBytes(nodeId));
1144
1282
  };
1145
1283
  export const decodeNodeId = (buffer) => {
1146
- const bytes = buffer.shiftN(8);
1284
+ const bytes = buffer.shiftN(NonNegativeInt.orThrow(8));
1147
1285
  return bytesToHex(bytes);
1148
1286
  };
1149
- export const encodeBase64Url256 = (buffer, string) => {
1150
- encodeLength(buffer, string);
1151
- buffer.extend(base64Url256ToBytes(string));
1152
- };
1153
- export const decodeBase64Url256WithLength = (buffer) => {
1154
- const length = decodeLength(buffer);
1155
- return decodeBase64Url256(buffer, length);
1156
- };
1157
1287
  // Small ints are encoded into ProtocolValueType, saving one byte per int.
1158
1288
  const isSmallInt = (value) => value >= 0 && value < 20;
1159
1289
  export const ProtocolValueType = {
1160
1290
  // 0-19 small ints
1161
1291
  // SQLite types
1162
- String: 20,
1163
- Number: 21,
1164
- Null: 22,
1165
- Binary: 23,
1292
+ String: NonNegativeInt.orThrow(20),
1293
+ Number: NonNegativeInt.orThrow(21),
1294
+ Null: NonNegativeInt.orThrow(22),
1295
+ Bytes: NonNegativeInt.orThrow(23),
1166
1296
  // We can add more types for other DBs or anything else later.
1167
1297
  // Optimized types
1168
- Id: 30,
1169
- Base64Url256: 31,
1170
- NonNegativeInt: 32,
1171
- Json: 33,
1298
+ NonNegativeInt: NonNegativeInt.orThrow(30),
1299
+ // String optimizations
1300
+ EmptyString: NonNegativeInt.orThrow(31), // 1 byte vs 2 bytes (50% reduction)
1301
+ Base64Url: NonNegativeInt.orThrow(32),
1302
+ Id: NonNegativeInt.orThrow(33),
1303
+ Json: NonNegativeInt.orThrow(34),
1172
1304
  // new Date().toISOString() - 24 bytes
1173
1305
  // encoded with fixed length - 8 bytes
1174
1306
  // encode as NonNegativeInt - 6 bytes (additional 25% reduction)
1175
- DateIsoWithNonNegativeTime: 34,
1176
- DateIsoWithNegativeTime: 35, // 9 bytes
1307
+ DateIsoWithNonNegativeTime: NonNegativeInt.orThrow(35),
1308
+ DateIsoWithNegativeTime: NonNegativeInt.orThrow(36), // 9 bytes
1177
1309
  // TODO: Operations (from 40)
1178
1310
  // Increment, Decrement, Patch, whatever.
1179
1311
  };
@@ -1184,9 +1316,13 @@ export const encodeSqliteValue = (buffer, value) => {
1184
1316
  }
1185
1317
  switch (typeof value) {
1186
1318
  case "string": {
1187
- const dateIsoString = DateIsoString.from(value);
1188
- if (dateIsoString.ok) {
1189
- const time = new Date(dateIsoString.value).getTime();
1319
+ if (value === "") {
1320
+ encodeNonNegativeInt(buffer, ProtocolValueType.EmptyString);
1321
+ return;
1322
+ }
1323
+ const dateIso = DateIso.fromParent(value);
1324
+ if (dateIso.ok) {
1325
+ const time = new Date(dateIso.value).getTime();
1190
1326
  if (NonNegativeInt.is(time)) {
1191
1327
  encodeNonNegativeInt(buffer, ProtocolValueType.DateIsoWithNonNegativeTime);
1192
1328
  encodeNonNegativeInt(buffer, time);
@@ -1197,25 +1333,31 @@ export const encodeSqliteValue = (buffer, value) => {
1197
1333
  }
1198
1334
  return;
1199
1335
  }
1200
- const base64Url256 = Base64Url256.from(value);
1201
- if (base64Url256.ok) {
1202
- if (base64Url256.value.length === idTypeValueLength) {
1203
- encodeNonNegativeInt(buffer, ProtocolValueType.Id);
1204
- buffer.extend(base64Url256ToBytes(base64Url256.value));
1205
- return;
1206
- }
1207
- encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url256);
1208
- encodeBase64Url256(buffer, base64Url256.value);
1336
+ const id = Id.fromParent(value);
1337
+ if (id.ok) {
1338
+ encodeNonNegativeInt(buffer, ProtocolValueType.Id);
1339
+ buffer.extend(idToIdBytes(id.value));
1209
1340
  return;
1210
1341
  }
1211
- const jsonValue = JsonValueFromString.fromParent(value);
1212
- if (jsonValue.ok) {
1213
- const jsonBytes = pack(jsonValue.value);
1342
+ const json = Json.fromParent(value);
1343
+ // Only encode as Json if it survives JSON.parse/JSON.stringify round-trip.
1344
+ // Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
1345
+ // which would cause data corruption if we don't verify round-trip safety.
1346
+ if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
1347
+ const jsonBytes = packr.pack(jsonToJsonValue(json.value));
1214
1348
  encodeNonNegativeInt(buffer, ProtocolValueType.Json);
1215
1349
  encodeLength(buffer, jsonBytes);
1216
1350
  buffer.extend(jsonBytes);
1217
1351
  return;
1218
1352
  }
1353
+ const base64Url = Base64Url.fromParent(value);
1354
+ if (base64Url.ok) {
1355
+ encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url);
1356
+ const bytes = base64UrlToUint8Array(base64Url.value);
1357
+ encodeLength(buffer, bytes);
1358
+ buffer.extend(bytes);
1359
+ return;
1360
+ }
1219
1361
  encodeNonNegativeInt(buffer, ProtocolValueType.String);
1220
1362
  encodeString(buffer, value);
1221
1363
  return;
@@ -1235,7 +1377,7 @@ export const encodeSqliteValue = (buffer, value) => {
1235
1377
  return;
1236
1378
  }
1237
1379
  }
1238
- encodeNonNegativeInt(buffer, ProtocolValueType.Binary);
1380
+ encodeNonNegativeInt(buffer, ProtocolValueType.Bytes);
1239
1381
  encodeLength(buffer, value);
1240
1382
  buffer.extend(value);
1241
1383
  };
@@ -1251,31 +1393,35 @@ export const decodeSqliteValue = (buffer) => {
1251
1393
  return decodeNumber(buffer);
1252
1394
  case ProtocolValueType.Null:
1253
1395
  return null;
1254
- case ProtocolValueType.Binary: {
1396
+ case ProtocolValueType.Bytes: {
1255
1397
  const length = decodeLength(buffer);
1256
1398
  return buffer.shiftN(length);
1257
1399
  }
1258
- case ProtocolValueType.Id: {
1400
+ case ProtocolValueType.Id:
1259
1401
  return decodeId(buffer);
1260
- }
1261
- case ProtocolValueType.Base64Url256:
1262
- return decodeBase64Url256WithLength(buffer);
1263
1402
  case ProtocolValueType.NonNegativeInt:
1264
1403
  return decodeNonNegativeInt(buffer);
1265
1404
  case ProtocolValueType.Json: {
1266
1405
  const length = decodeLength(buffer);
1267
1406
  const bytes = buffer.shiftN(length);
1268
- return JSON.stringify(unpack(bytes));
1407
+ return JSON.stringify(packr.unpack(bytes));
1269
1408
  }
1270
1409
  case ProtocolValueType.DateIsoWithNonNegativeTime:
1271
1410
  case ProtocolValueType.DateIsoWithNegativeTime: {
1272
1411
  const time = type === ProtocolValueType.DateIsoWithNonNegativeTime
1273
1412
  ? decodeNonNegativeInt(buffer)
1274
1413
  : decodeNumber(buffer);
1275
- const dateIsoString = DateIsoString.fromParent(new Date(time).toISOString());
1276
- if (!dateIsoString.ok)
1277
- throw new ProtocolDecodeError(dateIsoString.error.type);
1278
- return dateIsoString.value;
1414
+ const dateIso = DateIso.fromParent(new Date(time).toISOString());
1415
+ if (!dateIso.ok)
1416
+ throw new ProtocolDecodeError(dateIso.error.type);
1417
+ return dateIso.value;
1418
+ }
1419
+ case ProtocolValueType.EmptyString:
1420
+ return "";
1421
+ case ProtocolValueType.Base64Url: {
1422
+ const length = decodeLength(buffer);
1423
+ const bytes = buffer.shiftN(length);
1424
+ return uint8ArrayToBase64Url(bytes);
1279
1425
  }
1280
1426
  default:
1281
1427
  throw new ProtocolDecodeError("invalid ProtocolValueType");