@evolu/common 6.0.1-preview.9 → 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.
- package/README.md +2 -2
- package/dist/src/Array.d.ts +256 -12
- package/dist/src/Array.d.ts.map +1 -1
- package/dist/src/Array.js +215 -9
- package/dist/src/Assert.d.ts +0 -13
- package/dist/src/Assert.d.ts.map +1 -1
- package/dist/src/Assert.js +0 -15
- package/dist/src/Brand.d.ts +75 -0
- package/dist/src/Brand.d.ts.map +1 -0
- package/dist/src/Brand.js +1 -0
- package/dist/src/Buffer.d.ts +1 -1
- package/dist/src/Buffer.d.ts.map +1 -1
- package/dist/src/Buffer.js +8 -7
- package/dist/src/Cache.d.ts +44 -0
- package/dist/src/Cache.d.ts.map +1 -0
- package/dist/src/Cache.js +52 -0
- package/dist/src/Callbacks.d.ts +45 -12
- package/dist/src/Callbacks.d.ts.map +1 -1
- package/dist/src/Callbacks.js +14 -7
- package/dist/src/Console.d.ts +31 -6
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +72 -9
- package/dist/src/Crypto.d.ts +56 -42
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +40 -53
- package/dist/src/Evolu/Db.d.ts +162 -74
- package/dist/src/Evolu/Db.d.ts.map +1 -1
- package/dist/src/Evolu/Db.js +284 -702
- package/dist/src/Evolu/Diff.d.ts +3 -3
- package/dist/src/Evolu/Diff.d.ts.map +1 -1
- package/dist/src/Evolu/Diff.js +7 -5
- package/dist/src/Evolu/Evolu.d.ts +213 -133
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +189 -180
- package/dist/src/Evolu/Internal.d.ts +0 -2
- package/dist/src/Evolu/Internal.d.ts.map +1 -1
- package/dist/src/Evolu/Internal.js +0 -2
- package/dist/src/Evolu/LocalAuth.d.ts +150 -0
- package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
- package/dist/src/Evolu/LocalAuth.js +174 -0
- package/dist/src/Evolu/Owner.d.ts +273 -120
- package/dist/src/Evolu/Owner.d.ts.map +1 -1
- package/dist/src/Evolu/Owner.js +130 -104
- package/dist/src/Evolu/Platform.d.ts +9 -7
- package/dist/src/Evolu/Platform.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.d.ts +268 -240
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +595 -447
- package/dist/src/Evolu/Public.d.ts +6 -8
- package/dist/src/Evolu/Public.d.ts.map +1 -1
- package/dist/src/Evolu/Public.js +2 -3
- package/dist/src/Evolu/PublicKysely.d.ts.map +1 -1
- package/dist/src/Evolu/PublicKysely.js +3 -4
- package/dist/src/Evolu/Query.d.ts +2 -1
- package/dist/src/Evolu/Query.d.ts.map +1 -1
- package/dist/src/Evolu/Query.js +1 -1
- package/dist/src/Evolu/Relay.d.ts +91 -8
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +214 -88
- package/dist/src/Evolu/Schema.d.ts +125 -47
- package/dist/src/Evolu/Schema.d.ts.map +1 -1
- package/dist/src/Evolu/Schema.js +175 -31
- package/dist/src/Evolu/Storage.d.ts +249 -27
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +198 -92
- package/dist/src/Evolu/Sync.d.ts +68 -13
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +469 -20
- package/dist/src/Evolu/Timestamp.d.ts +83 -30
- package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
- package/dist/src/Evolu/Timestamp.js +79 -34
- package/dist/src/Identicon.d.ts +35 -0
- package/dist/src/Identicon.d.ts.map +1 -0
- package/dist/src/Identicon.js +143 -0
- package/dist/src/Instances.d.ts +34 -0
- package/dist/src/Instances.d.ts.map +1 -0
- package/dist/src/Instances.js +44 -0
- package/dist/src/Number.d.ts +4 -3
- package/dist/src/Number.d.ts.map +1 -1
- package/dist/src/Number.js +5 -4
- package/dist/src/Object.d.ts +10 -4
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +9 -3
- package/dist/src/Platform.d.ts +20 -0
- package/dist/src/Platform.d.ts.map +1 -0
- package/dist/src/Platform.js +22 -0
- package/dist/src/Random.d.ts +3 -2
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Relation.d.ts +79 -0
- package/dist/src/Relation.d.ts.map +1 -0
- package/dist/src/Relation.js +127 -0
- package/dist/src/Resources.d.ts +118 -0
- package/dist/src/Resources.d.ts.map +1 -0
- package/dist/src/Resources.js +197 -0
- package/dist/src/Result.d.ts +184 -52
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Result.js +30 -241
- package/dist/src/Skiplist.js +2 -1
- package/dist/src/Sqlite.d.ts +89 -5
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +136 -9
- package/dist/src/Task.d.ts +586 -0
- package/dist/src/Task.d.ts.map +1 -0
- package/dist/src/Task.js +469 -0
- package/dist/src/Time.d.ts +66 -1
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +99 -5
- package/dist/src/Type.d.ts +676 -343
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +718 -467
- package/dist/src/Types.d.ts +1 -75
- package/dist/src/Types.d.ts.map +1 -1
- package/dist/src/WebSocket.d.ts +5 -2
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +12 -18
- package/dist/src/Worker.d.ts +39 -11
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +22 -4
- package/dist/src/index.d.ts +8 -3
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +8 -3
- package/package.json +14 -13
- package/src/Array.ts +301 -19
- package/src/Assert.ts +0 -21
- package/src/Brand.ts +75 -0
- package/src/Buffer.ts +7 -7
- package/src/Cache.ts +85 -0
- package/src/Callbacks.ts +62 -22
- package/src/Console.ts +91 -11
- package/src/Crypto.ts +94 -90
- package/src/Evolu/Db.ts +519 -1026
- package/src/Evolu/Diff.ts +7 -5
- package/src/Evolu/Evolu.ts +476 -360
- package/src/Evolu/Internal.ts +0 -2
- package/src/Evolu/LocalAuth.ts +463 -0
- package/src/Evolu/Owner.ts +355 -228
- package/src/Evolu/Platform.ts +9 -9
- package/src/Evolu/Protocol.ts +846 -742
- package/src/Evolu/Public.ts +7 -14
- package/src/Evolu/PublicKysely.ts +4 -5
- package/src/Evolu/Query.ts +3 -2
- package/src/Evolu/Relay.ts +406 -103
- package/src/Evolu/Schema.ts +323 -91
- package/src/Evolu/Storage.ts +559 -137
- package/src/Evolu/Sync.ts +819 -36
- package/src/Evolu/Timestamp.ts +90 -58
- package/src/Identicon.ts +197 -0
- package/src/Instances.ts +90 -0
- package/src/Number.ts +6 -10
- package/src/Object.ts +13 -5
- package/src/Platform.ts +26 -0
- package/src/Random.ts +3 -2
- package/src/Relation.ts +234 -0
- package/src/Resources.ts +367 -0
- package/src/Result.ts +191 -54
- package/src/Skiplist.ts +1 -1
- package/src/Sqlite.ts +152 -17
- package/src/Task.ts +901 -0
- package/src/Time.ts +180 -5
- package/src/Type.ts +1135 -730
- package/src/Types.ts +1 -77
- package/src/WebSocket.ts +27 -25
- package/src/Worker.ts +72 -23
- package/src/index.ts +8 -3
- package/dist/src/Evolu/Config.d.ts +0 -69
- package/dist/src/Evolu/Config.d.ts.map +0 -1
- package/dist/src/Evolu/Config.js +0 -9
- package/dist/src/Evolu/Kysely.d.ts +0 -6
- package/dist/src/Evolu/Kysely.d.ts.map +0 -1
- package/dist/src/Evolu/Kysely.js +0 -21
- package/dist/src/ManyToManyMap.d.ts +0 -26
- package/dist/src/ManyToManyMap.d.ts.map +0 -1
- package/dist/src/ManyToManyMap.js +0 -92
- package/dist/src/NanoId.d.ts +0 -27
- package/dist/src/NanoId.d.ts.map +0 -1
- package/dist/src/NanoId.js +0 -6
- package/dist/src/Promise.d.ts +0 -180
- package/dist/src/Promise.d.ts.map +0 -1
- package/dist/src/Promise.js +0 -176
- package/src/Evolu/Config.ts +0 -83
- package/src/Evolu/Kysely.ts +0 -38
- package/src/ManyToManyMap.ts +0 -140
- package/src/NanoId.ts +0 -39
- 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
|
|
7
|
-
*
|
|
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
|
|
20
|
+
* transfer, ownership, real-time broadcasting, request-response semantics, and
|
|
21
|
+
* error handling.
|
|
15
22
|
*
|
|
16
|
-
* ### Message
|
|
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
|
-
* |
|
|
24
|
-
* |
|
|
25
|
-
* | -
|
|
26
|
-
* | - {@link
|
|
27
|
-
* |
|
|
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
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
68
|
-
*
|
|
69
|
-
* responds without a {@link
|
|
70
|
-
* signals it wants data. If the non-initiator detects an issue, it
|
|
71
|
-
* error code via the `Error` field in the header back to the
|
|
72
|
-
* relay-to-relay or P2P sync, both sides may require the
|
|
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
|
|
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
|
|
81
|
-
*
|
|
82
|
-
* - {@link ProtocolSyncError}: A
|
|
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
|
|
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 `
|
|
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
|
|
97
|
+
* ### Message size limit
|
|
91
98
|
*
|
|
92
99
|
* The protocol enforces a strict maximum size for all messages, defined by
|
|
93
|
-
* {@link
|
|
94
|
-
* less than or equal to this limit,
|
|
95
|
-
*
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
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
181
|
import { bytesToHex, bytesToUtf8, createBuffer, hexToBytes, utf8ToBytes, } from "../Buffer.js";
|
|
146
|
-
import {
|
|
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 {
|
|
152
|
-
import {
|
|
153
|
-
import {
|
|
154
|
-
import {
|
|
155
|
-
/**
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
|
|
208
|
-
totalMaxSize: maxSize ??
|
|
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
|
|
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
|
|
233
|
-
*
|
|
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.
|
|
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, {
|
|
252
|
-
|
|
253
|
-
|
|
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)(
|
|
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 =
|
|
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(
|
|
284
|
-
|
|
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([
|
|
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([
|
|
294
|
-
buffers.header.extend(options.writeKey
|
|
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(
|
|
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,151 +538,220 @@ const createRunLengthEncoder = (encodeValue) => {
|
|
|
466
538
|
unwrap: () => buffer.unwrap(),
|
|
467
539
|
};
|
|
468
540
|
};
|
|
469
|
-
export const applyProtocolMessageAsClient = (deps) => (inputMessage,
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
542
|
-
|
|
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 (
|
|
557
|
-
const
|
|
558
|
-
if (!
|
|
559
|
-
return ok(
|
|
560
|
-
type: "
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
752
|
return err({
|
|
612
753
|
type: "ProtocolInvalidDataError",
|
|
613
|
-
data,
|
|
754
|
+
data: inputMessage,
|
|
614
755
|
error,
|
|
615
756
|
});
|
|
616
757
|
}
|
|
@@ -621,7 +762,7 @@ const decodeVersionAndOwner = (input) => {
|
|
|
621
762
|
// to enable version negotiation and owner identification before any other
|
|
622
763
|
// processing occurs.
|
|
623
764
|
const version = decodeNonNegativeInt(input);
|
|
624
|
-
const ownerId =
|
|
765
|
+
const ownerId = decodeId(input);
|
|
625
766
|
return [version, ownerId];
|
|
626
767
|
};
|
|
627
768
|
/**
|
|
@@ -645,30 +786,13 @@ const decodeMessages = (buffer) => {
|
|
|
645
786
|
}
|
|
646
787
|
return messages;
|
|
647
788
|
};
|
|
648
|
-
const sync = (deps) => (
|
|
649
|
-
const ranges = decodeRanges(input);
|
|
650
|
-
if (!isNonEmptyReadonlyArray(ranges)) {
|
|
651
|
-
// Nothing to sync.
|
|
652
|
-
return ok(null);
|
|
653
|
-
}
|
|
654
|
-
const binaryOwnerId = binaryOwnerIdToOwnerId(ownerId);
|
|
789
|
+
const sync = (deps) => (ranges, output, ownerIdBytes) => {
|
|
655
790
|
const outputInitialSize = output.getSize();
|
|
656
|
-
const
|
|
657
|
-
// Only the relay (non-initiator) reports sync errors, not the client (initiator).
|
|
658
|
-
if (role === "initiator") {
|
|
659
|
-
return ok(null);
|
|
660
|
-
}
|
|
661
|
-
const message = createProtocolMessageBuffer(binaryOwnerId, {
|
|
662
|
-
type: "non-initiator",
|
|
663
|
-
errorCode: ProtocolErrorCode.SyncError,
|
|
664
|
-
});
|
|
665
|
-
return ok(message.unwrap());
|
|
666
|
-
};
|
|
667
|
-
const storageSize = deps.storage.getSize(ownerId);
|
|
791
|
+
const storageSize = deps.storage.getSize(ownerIdBytes);
|
|
668
792
|
if (storageSize == null)
|
|
669
|
-
return
|
|
793
|
+
return err(ProtocolErrorCode.SyncError);
|
|
670
794
|
let prevUpperBound = null;
|
|
671
|
-
let prevIndex = 0;
|
|
795
|
+
let prevIndex = NonNegativeInt.orThrow(0);
|
|
672
796
|
let skip = false;
|
|
673
797
|
let nonSkipRangeAdded = false;
|
|
674
798
|
const skipRange = (range) => {
|
|
@@ -698,7 +822,7 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
698
822
|
};
|
|
699
823
|
// When we don't have a space...
|
|
700
824
|
const addFingerprintForRemainingRange = (begin) => {
|
|
701
|
-
const fingerprint = deps.storage.fingerprint(
|
|
825
|
+
const fingerprint = deps.storage.fingerprint(ownerIdBytes, begin, storageSize);
|
|
702
826
|
if (!fingerprint)
|
|
703
827
|
return false;
|
|
704
828
|
// There is always a space for a ramaining range.
|
|
@@ -712,30 +836,30 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
712
836
|
for (const range of ranges) {
|
|
713
837
|
const currentUpperBound = range.upperBound;
|
|
714
838
|
const lower = prevIndex;
|
|
715
|
-
let upper = deps.storage.findLowerBound(
|
|
839
|
+
let upper = deps.storage.findLowerBound(ownerIdBytes, prevIndex, storageSize, currentUpperBound);
|
|
716
840
|
if (upper == null)
|
|
717
|
-
return
|
|
841
|
+
return err(ProtocolErrorCode.SyncError);
|
|
718
842
|
switch (range.type) {
|
|
719
843
|
case RangeType.Skip: {
|
|
720
844
|
skipRange(range);
|
|
721
845
|
break;
|
|
722
846
|
}
|
|
723
847
|
case RangeType.Fingerprint: {
|
|
724
|
-
const ourFingerprint = deps.storage.fingerprint(
|
|
848
|
+
const ourFingerprint = deps.storage.fingerprint(ownerIdBytes, lower, upper);
|
|
725
849
|
if (ourFingerprint == null)
|
|
726
|
-
return
|
|
850
|
+
return err(ProtocolErrorCode.SyncError);
|
|
727
851
|
if (eqArrayNumber(range.fingerprint, ourFingerprint)) {
|
|
728
852
|
skipRange(range);
|
|
729
853
|
}
|
|
730
854
|
else {
|
|
731
855
|
if (output.canSplitRange()) {
|
|
732
856
|
coalesceSkipsBeforeAdd();
|
|
733
|
-
splitRange(deps)(
|
|
857
|
+
splitRange(deps)(ownerIdBytes, lower, upper, currentUpperBound, output);
|
|
734
858
|
}
|
|
735
859
|
else {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
860
|
+
return addFingerprintForRemainingRange(upper)
|
|
861
|
+
? ok(true)
|
|
862
|
+
: err(ProtocolErrorCode.SyncError);
|
|
739
863
|
}
|
|
740
864
|
}
|
|
741
865
|
break;
|
|
@@ -744,19 +868,19 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
744
868
|
let endBound = currentUpperBound;
|
|
745
869
|
const timestampsWeNeed = new Map(range.timestamps.map((t) => [t.join(), true]));
|
|
746
870
|
const ourTimestamps = createTimestampsBuffer();
|
|
747
|
-
let
|
|
871
|
+
let cantReadDbChange = false;
|
|
748
872
|
let exceeded = false;
|
|
749
|
-
deps.storage.iterate(
|
|
873
|
+
deps.storage.iterate(ownerIdBytes, lower, upper, (timestamp, index) => {
|
|
750
874
|
const timestampString = timestamp.join();
|
|
751
|
-
const timestampBinary =
|
|
875
|
+
const timestampBinary = timestampBytesToTimestamp(timestamp);
|
|
752
876
|
let message = null;
|
|
753
877
|
if (timestampsWeNeed.has(timestampString)) {
|
|
754
878
|
timestampsWeNeed.delete(timestampString);
|
|
755
879
|
}
|
|
756
880
|
else {
|
|
757
|
-
const dbChange = deps.storage.readDbChange(
|
|
881
|
+
const dbChange = deps.storage.readDbChange(ownerIdBytes, timestamp);
|
|
758
882
|
if (dbChange == null) {
|
|
759
|
-
|
|
883
|
+
cantReadDbChange = true;
|
|
760
884
|
return false;
|
|
761
885
|
}
|
|
762
886
|
message = {
|
|
@@ -775,8 +899,8 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
775
899
|
output.addMessage(message);
|
|
776
900
|
return true;
|
|
777
901
|
});
|
|
778
|
-
if (
|
|
779
|
-
return
|
|
902
|
+
if (cantReadDbChange) {
|
|
903
|
+
return err(ProtocolErrorCode.SyncError);
|
|
780
904
|
}
|
|
781
905
|
const addRange = () => {
|
|
782
906
|
coalesceSkipsBeforeAdd();
|
|
@@ -789,9 +913,9 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
789
913
|
if (exceeded) {
|
|
790
914
|
addRange();
|
|
791
915
|
if (!addFingerprintForRemainingRange(upper)) {
|
|
792
|
-
return
|
|
916
|
+
return err(ProtocolErrorCode.SyncError);
|
|
793
917
|
}
|
|
794
|
-
return ok(
|
|
918
|
+
return ok(true);
|
|
795
919
|
}
|
|
796
920
|
// If we need something, we have to respond with our timestamps.
|
|
797
921
|
if (timestampsWeNeed.size > 0) {
|
|
@@ -808,10 +932,10 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
808
932
|
}
|
|
809
933
|
// If all ranges were skipped, there are no changes and sync is complete.
|
|
810
934
|
const hasChange = output.getSize() > outputInitialSize;
|
|
811
|
-
return ok(hasChange
|
|
935
|
+
return ok(hasChange);
|
|
812
936
|
};
|
|
813
937
|
const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
|
|
814
|
-
const itemCount = (upper - lower);
|
|
938
|
+
const itemCount = NonNegativeInt.orThrow(upper - lower);
|
|
815
939
|
const buckets = computeBalancedBuckets(itemCount);
|
|
816
940
|
if (!buckets.ok) {
|
|
817
941
|
const range = {
|
|
@@ -819,8 +943,8 @@ const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
|
|
|
819
943
|
upperBound,
|
|
820
944
|
timestamps: createTimestampsBuffer(),
|
|
821
945
|
};
|
|
822
|
-
deps.storage.iterate(ownerId, 0, itemCount, (timestamp) => {
|
|
823
|
-
range.timestamps.add(
|
|
946
|
+
deps.storage.iterate(ownerId, NonNegativeInt.orThrow(0), itemCount, (timestamp) => {
|
|
947
|
+
range.timestamps.add(timestampBytesToTimestamp(timestamp));
|
|
824
948
|
return true;
|
|
825
949
|
});
|
|
826
950
|
buffer.addRange(range);
|
|
@@ -829,7 +953,10 @@ const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
|
|
|
829
953
|
// Check Storage.ts `fingerprint` and `fingerprintRanges` docs.
|
|
830
954
|
const fingerprintRangesBuckets = lower === 0
|
|
831
955
|
? buckets.value
|
|
832
|
-
: [
|
|
956
|
+
: [
|
|
957
|
+
lower,
|
|
958
|
+
...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)),
|
|
959
|
+
];
|
|
833
960
|
const fingerprintRanges = deps.storage.fingerprintRanges(ownerId, fingerprintRangesBuckets, upperBound);
|
|
834
961
|
// Errors are handled by the storage.
|
|
835
962
|
if (fingerprintRanges == null)
|
|
@@ -845,7 +972,7 @@ const decodeRanges = (buffer) => {
|
|
|
845
972
|
const rangesCount = decodeNonNegativeInt(buffer);
|
|
846
973
|
if (rangesCount === 0)
|
|
847
974
|
return [];
|
|
848
|
-
const timestampsCount = (rangesCount - 1);
|
|
975
|
+
const timestampsCount = NonNegativeInt.orThrow(rangesCount - 1);
|
|
849
976
|
const timestamps = decodeTimestamps(buffer, timestampsCount);
|
|
850
977
|
const rangeTypes = [];
|
|
851
978
|
for (let i = 0; i < rangesCount; i++) {
|
|
@@ -863,7 +990,7 @@ const decodeRanges = (buffer) => {
|
|
|
863
990
|
const ranges = [];
|
|
864
991
|
for (let i = 0; i < rangesCount; i++) {
|
|
865
992
|
const upperBound = i < timestampsCount
|
|
866
|
-
?
|
|
993
|
+
? timestampToTimestampBytes(timestamps[i])
|
|
867
994
|
: InfiniteUpperBound;
|
|
868
995
|
const rangeType = rangeTypes[i];
|
|
869
996
|
switch (rangeType) {
|
|
@@ -880,7 +1007,7 @@ const decodeRanges = (buffer) => {
|
|
|
880
1007
|
break;
|
|
881
1008
|
}
|
|
882
1009
|
case RangeType.Timestamps: {
|
|
883
|
-
const timestamps = decodeTimestamps(buffer).map(
|
|
1010
|
+
const timestamps = decodeTimestamps(buffer).map(timestampToTimestampBytes);
|
|
884
1011
|
ranges.push({
|
|
885
1012
|
type: RangeType.Timestamps,
|
|
886
1013
|
upperBound,
|
|
@@ -901,7 +1028,7 @@ const decodeTimestamps = (buffer, length) => {
|
|
|
901
1028
|
const deltaMillis = decodeNonNegativeInt(buffer);
|
|
902
1029
|
const millis = Millis.from(previousMillis + deltaMillis);
|
|
903
1030
|
if (!millis.ok)
|
|
904
|
-
throw new
|
|
1031
|
+
throw new ProtocolDecodeError(millis.error.type);
|
|
905
1032
|
millises.push(millis.value);
|
|
906
1033
|
previousMillis = millis.value;
|
|
907
1034
|
}
|
|
@@ -910,7 +1037,7 @@ const decodeTimestamps = (buffer, length) => {
|
|
|
910
1037
|
while (counterIndex < length) {
|
|
911
1038
|
const counter = Counter.from(decodeNonNegativeInt(buffer));
|
|
912
1039
|
if (!counter.ok)
|
|
913
|
-
throw new
|
|
1040
|
+
throw new ProtocolDecodeError(counter.error.type);
|
|
914
1041
|
const runLength = decodeNonNegativeInt(buffer);
|
|
915
1042
|
for (let i = 0; i < runLength; i++) {
|
|
916
1043
|
counters.push(counter.value);
|
|
@@ -937,79 +1064,21 @@ const decodeTimestamps = (buffer, length) => {
|
|
|
937
1064
|
}
|
|
938
1065
|
return timestamps;
|
|
939
1066
|
};
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
export const ownerIdToBinaryOwnerId = (ownerId) => base64Url256ToBytes(ownerId);
|
|
944
|
-
export const binaryOwnerIdToOwnerId = (binaryOwnerId) => decodeOwnerId(createBuffer(binaryOwnerId));
|
|
945
|
-
/**
|
|
946
|
-
* Alphabet used for Base64Url encoding. This is copied from the `nanoid`
|
|
947
|
-
* library to avoid dependency on a specific version of `nanoid`.
|
|
948
|
-
*/
|
|
949
|
-
const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
|
|
950
|
-
/**
|
|
951
|
-
* Converts a Base64Url string to a Uint8Array for binary storage. This encoding
|
|
952
|
-
* is more space-efficient than UTF-8 for Base64Url strings.
|
|
953
|
-
*/
|
|
954
|
-
export const base64Url256ToBytes = (string) => {
|
|
955
|
-
const totalBits = string.length * 6; // 6 bits per character
|
|
956
|
-
const byteLength = Math.ceil(totalBits / 8);
|
|
957
|
-
const value = new globalThis.Uint8Array(byteLength);
|
|
958
|
-
let bitBuffer = 0;
|
|
959
|
-
let bitsInBuffer = 0;
|
|
960
|
-
let byteIndex = 0;
|
|
961
|
-
for (const char of string) {
|
|
962
|
-
const charValue = urlAlphabet.indexOf(char);
|
|
963
|
-
bitBuffer = (bitBuffer << 6) | charValue;
|
|
964
|
-
bitsInBuffer += 6;
|
|
965
|
-
while (bitsInBuffer >= 8) {
|
|
966
|
-
bitsInBuffer -= 8;
|
|
967
|
-
value[byteIndex++] = (bitBuffer >> bitsInBuffer) & 0xff;
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
if (bitsInBuffer > 0 && byteIndex < byteLength) {
|
|
971
|
-
value[byteIndex] = (bitBuffer << (8 - bitsInBuffer)) & 0xff;
|
|
972
|
-
}
|
|
973
|
-
return value;
|
|
1067
|
+
const decodeId = (buffer) => {
|
|
1068
|
+
const bytes = buffer.shiftN(idBytesTypeValueLength);
|
|
1069
|
+
return idBytesToId(bytes);
|
|
974
1070
|
};
|
|
975
|
-
export const decodeBase64Url256 = (buffer, stringLength) => {
|
|
976
|
-
const bytes = buffer.shiftN(Math.ceil((stringLength * 6) / 8));
|
|
977
|
-
let bitBuffer = 0;
|
|
978
|
-
let bitsInBuffer = 0;
|
|
979
|
-
let string = "";
|
|
980
|
-
for (const byte of bytes) {
|
|
981
|
-
bitBuffer = (bitBuffer << 8) | byte;
|
|
982
|
-
bitsInBuffer += 8;
|
|
983
|
-
while (bitsInBuffer >= 6) {
|
|
984
|
-
bitsInBuffer -= 6;
|
|
985
|
-
if (string.length < stringLength) {
|
|
986
|
-
const charValue = (bitBuffer >> bitsInBuffer) & 0x3f;
|
|
987
|
-
if (charValue < 0 || charValue >= urlAlphabet.length) {
|
|
988
|
-
throw new ProtocolDecodeError("invalid charValue");
|
|
989
|
-
}
|
|
990
|
-
string += urlAlphabet[charValue];
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
const result = Base64Url256.from(string);
|
|
995
|
-
if (!result.ok)
|
|
996
|
-
throw new ProtocolDecodeError(result.error.type);
|
|
997
|
-
return result.value;
|
|
998
|
-
};
|
|
999
|
-
const decodeId = (buffer) => decodeBase64Url256(buffer, idTypeValueLength);
|
|
1000
|
-
/** Not all 16 bytes are valid {@link OwnerId}. */
|
|
1001
|
-
const decodeOwnerId = (buffer) => decodeId(buffer);
|
|
1002
1071
|
/**
|
|
1003
1072
|
* Evolu uses MessagePack to handle all number variants except for
|
|
1004
1073
|
* NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
|
|
1005
1074
|
*/
|
|
1006
1075
|
export const encodeNumber = (buffer, number) => {
|
|
1007
|
-
buffer.extend(pack(number));
|
|
1076
|
+
buffer.extend(packr.pack(number));
|
|
1008
1077
|
};
|
|
1009
1078
|
export const decodeNumber = (buffer) => {
|
|
1010
1079
|
let number;
|
|
1011
1080
|
let end;
|
|
1012
|
-
unpackMultiple(buffer.unwrap(), (n, _, e) => {
|
|
1081
|
+
packr.unpackMultiple(buffer.unwrap(), (n, _, e) => {
|
|
1013
1082
|
number = n;
|
|
1014
1083
|
end = e;
|
|
1015
1084
|
return false;
|
|
@@ -1023,29 +1092,73 @@ export const decodeNumber = (buffer) => {
|
|
|
1023
1092
|
buffer.shiftN(endResult.value);
|
|
1024
1093
|
return numberResult.value;
|
|
1025
1094
|
};
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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;
|
|
1029
1132
|
};
|
|
1030
1133
|
/**
|
|
1031
1134
|
* Encodes and encrypts a {@link DbChange} using the provided owner's encryption
|
|
1032
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.
|
|
1033
1140
|
*/
|
|
1034
|
-
export const encodeAndEncryptDbChange = (deps) => (
|
|
1141
|
+
export const encodeAndEncryptDbChange = (deps) => (message, key) => {
|
|
1035
1142
|
const buffer = createBuffer();
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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);
|
|
1041
1155
|
encodeLength(buffer, entries);
|
|
1042
1156
|
for (const [column, value] of entries) {
|
|
1043
|
-
|
|
1157
|
+
encodeString(buffer, column);
|
|
1044
1158
|
encodeSqliteValue(buffer, value);
|
|
1045
1159
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
buffer.extend(new Uint8Array(paddingLength));
|
|
1160
|
+
// Add PADMÉ padding (ignored during decoding)
|
|
1161
|
+
buffer.extend(createPadmePadding(buffer.getLength()));
|
|
1049
1162
|
const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(buffer.unwrap(), key);
|
|
1050
1163
|
buffer.reset();
|
|
1051
1164
|
buffer.extend(nonce);
|
|
@@ -1054,30 +1167,57 @@ export const encodeAndEncryptDbChange = (deps) => (change, key) => {
|
|
|
1054
1167
|
return buffer.unwrap();
|
|
1055
1168
|
};
|
|
1056
1169
|
/**
|
|
1057
|
-
* Decrypts and decodes an {@link
|
|
1058
|
-
* 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.
|
|
1059
1173
|
*/
|
|
1060
|
-
export const decryptAndDecodeDbChange = (deps) => (
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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);
|
|
1077
1212
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
return err({
|
|
1215
|
+
type: "ProtocolInvalidDataError",
|
|
1216
|
+
data: message.change,
|
|
1217
|
+
error,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1081
1221
|
/**
|
|
1082
1222
|
* Encodes a non-negative integer into a variable-length integer format. It's
|
|
1083
1223
|
* more efficient than encoding via {@link encodeNumber}.
|
|
@@ -1124,7 +1264,7 @@ export const decodeNonNegativeInt = (buffer) => {
|
|
|
1124
1264
|
return int.value;
|
|
1125
1265
|
};
|
|
1126
1266
|
export const encodeLength = (buffer, value) => {
|
|
1127
|
-
encodeNonNegativeInt(buffer, value.length);
|
|
1267
|
+
encodeNonNegativeInt(buffer, NonNegativeInt.orThrow(value.length));
|
|
1128
1268
|
};
|
|
1129
1269
|
export const decodeLength = decodeNonNegativeInt;
|
|
1130
1270
|
export const encodeString = (buffer, value) => {
|
|
@@ -1141,37 +1281,31 @@ export const encodeNodeId = (buffer, nodeId) => {
|
|
|
1141
1281
|
buffer.extend(hexToBytes(nodeId));
|
|
1142
1282
|
};
|
|
1143
1283
|
export const decodeNodeId = (buffer) => {
|
|
1144
|
-
const bytes = buffer.shiftN(8);
|
|
1284
|
+
const bytes = buffer.shiftN(NonNegativeInt.orThrow(8));
|
|
1145
1285
|
return bytesToHex(bytes);
|
|
1146
1286
|
};
|
|
1147
|
-
export const encodeBase64Url256 = (buffer, string) => {
|
|
1148
|
-
encodeLength(buffer, string);
|
|
1149
|
-
buffer.extend(base64Url256ToBytes(string));
|
|
1150
|
-
};
|
|
1151
|
-
export const decodeBase64Url256WithLength = (buffer) => {
|
|
1152
|
-
const length = decodeLength(buffer);
|
|
1153
|
-
return decodeBase64Url256(buffer, length);
|
|
1154
|
-
};
|
|
1155
1287
|
// Small ints are encoded into ProtocolValueType, saving one byte per int.
|
|
1156
1288
|
const isSmallInt = (value) => value >= 0 && value < 20;
|
|
1157
1289
|
export const ProtocolValueType = {
|
|
1158
1290
|
// 0-19 small ints
|
|
1159
1291
|
// SQLite types
|
|
1160
|
-
String: 20,
|
|
1161
|
-
Number: 21,
|
|
1162
|
-
Null: 22,
|
|
1163
|
-
|
|
1292
|
+
String: NonNegativeInt.orThrow(20),
|
|
1293
|
+
Number: NonNegativeInt.orThrow(21),
|
|
1294
|
+
Null: NonNegativeInt.orThrow(22),
|
|
1295
|
+
Bytes: NonNegativeInt.orThrow(23),
|
|
1164
1296
|
// We can add more types for other DBs or anything else later.
|
|
1165
1297
|
// Optimized types
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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),
|
|
1170
1304
|
// new Date().toISOString() - 24 bytes
|
|
1171
1305
|
// encoded with fixed length - 8 bytes
|
|
1172
1306
|
// encode as NonNegativeInt - 6 bytes (additional 25% reduction)
|
|
1173
|
-
DateIsoWithNonNegativeTime:
|
|
1174
|
-
DateIsoWithNegativeTime:
|
|
1307
|
+
DateIsoWithNonNegativeTime: NonNegativeInt.orThrow(35),
|
|
1308
|
+
DateIsoWithNegativeTime: NonNegativeInt.orThrow(36), // 9 bytes
|
|
1175
1309
|
// TODO: Operations (from 40)
|
|
1176
1310
|
// Increment, Decrement, Patch, whatever.
|
|
1177
1311
|
};
|
|
@@ -1182,9 +1316,13 @@ export const encodeSqliteValue = (buffer, value) => {
|
|
|
1182
1316
|
}
|
|
1183
1317
|
switch (typeof value) {
|
|
1184
1318
|
case "string": {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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();
|
|
1188
1326
|
if (NonNegativeInt.is(time)) {
|
|
1189
1327
|
encodeNonNegativeInt(buffer, ProtocolValueType.DateIsoWithNonNegativeTime);
|
|
1190
1328
|
encodeNonNegativeInt(buffer, time);
|
|
@@ -1195,25 +1333,31 @@ export const encodeSqliteValue = (buffer, value) => {
|
|
|
1195
1333
|
}
|
|
1196
1334
|
return;
|
|
1197
1335
|
}
|
|
1198
|
-
const
|
|
1199
|
-
if (
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
buffer.extend(base64Url256ToBytes(base64Url256.value));
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url256);
|
|
1206
|
-
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));
|
|
1207
1340
|
return;
|
|
1208
1341
|
}
|
|
1209
|
-
const
|
|
1210
|
-
if
|
|
1211
|
-
|
|
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));
|
|
1212
1348
|
encodeNonNegativeInt(buffer, ProtocolValueType.Json);
|
|
1213
1349
|
encodeLength(buffer, jsonBytes);
|
|
1214
1350
|
buffer.extend(jsonBytes);
|
|
1215
1351
|
return;
|
|
1216
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
|
+
}
|
|
1217
1361
|
encodeNonNegativeInt(buffer, ProtocolValueType.String);
|
|
1218
1362
|
encodeString(buffer, value);
|
|
1219
1363
|
return;
|
|
@@ -1233,7 +1377,7 @@ export const encodeSqliteValue = (buffer, value) => {
|
|
|
1233
1377
|
return;
|
|
1234
1378
|
}
|
|
1235
1379
|
}
|
|
1236
|
-
encodeNonNegativeInt(buffer, ProtocolValueType.
|
|
1380
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Bytes);
|
|
1237
1381
|
encodeLength(buffer, value);
|
|
1238
1382
|
buffer.extend(value);
|
|
1239
1383
|
};
|
|
@@ -1249,31 +1393,35 @@ export const decodeSqliteValue = (buffer) => {
|
|
|
1249
1393
|
return decodeNumber(buffer);
|
|
1250
1394
|
case ProtocolValueType.Null:
|
|
1251
1395
|
return null;
|
|
1252
|
-
case ProtocolValueType.
|
|
1396
|
+
case ProtocolValueType.Bytes: {
|
|
1253
1397
|
const length = decodeLength(buffer);
|
|
1254
1398
|
return buffer.shiftN(length);
|
|
1255
1399
|
}
|
|
1256
|
-
case ProtocolValueType.Id:
|
|
1400
|
+
case ProtocolValueType.Id:
|
|
1257
1401
|
return decodeId(buffer);
|
|
1258
|
-
}
|
|
1259
|
-
case ProtocolValueType.Base64Url256:
|
|
1260
|
-
return decodeBase64Url256WithLength(buffer);
|
|
1261
1402
|
case ProtocolValueType.NonNegativeInt:
|
|
1262
1403
|
return decodeNonNegativeInt(buffer);
|
|
1263
1404
|
case ProtocolValueType.Json: {
|
|
1264
1405
|
const length = decodeLength(buffer);
|
|
1265
1406
|
const bytes = buffer.shiftN(length);
|
|
1266
|
-
return JSON.stringify(unpack(bytes));
|
|
1407
|
+
return JSON.stringify(packr.unpack(bytes));
|
|
1267
1408
|
}
|
|
1268
1409
|
case ProtocolValueType.DateIsoWithNonNegativeTime:
|
|
1269
1410
|
case ProtocolValueType.DateIsoWithNegativeTime: {
|
|
1270
1411
|
const time = type === ProtocolValueType.DateIsoWithNonNegativeTime
|
|
1271
1412
|
? decodeNonNegativeInt(buffer)
|
|
1272
1413
|
: decodeNumber(buffer);
|
|
1273
|
-
const
|
|
1274
|
-
if (!
|
|
1275
|
-
throw new ProtocolDecodeError(
|
|
1276
|
-
return
|
|
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);
|
|
1277
1425
|
}
|
|
1278
1426
|
default:
|
|
1279
1427
|
throw new ProtocolDecodeError("invalid ProtocolValueType");
|