@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.
- 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 +214 -134
- 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 +600 -454
- 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 +15 -14
- 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 +477 -361
- 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 +842 -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
|
-
import {
|
|
146
|
-
import {
|
|
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 {
|
|
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,155 +538,222 @@ 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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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 =
|
|
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) => (
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
839
|
+
let upper = deps.storage.findLowerBound(ownerIdBytes, prevIndex, storageSize, currentUpperBound);
|
|
718
840
|
if (upper == null)
|
|
719
|
-
return
|
|
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(
|
|
848
|
+
const ourFingerprint = deps.storage.fingerprint(ownerIdBytes, lower, upper);
|
|
727
849
|
if (ourFingerprint == null)
|
|
728
|
-
return
|
|
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)(
|
|
857
|
+
splitRange(deps)(ownerIdBytes, lower, upper, currentUpperBound, output);
|
|
736
858
|
}
|
|
737
859
|
else {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
|
871
|
+
let cantReadDbChange = false;
|
|
750
872
|
let exceeded = false;
|
|
751
|
-
deps.storage.iterate(
|
|
873
|
+
deps.storage.iterate(ownerIdBytes, lower, upper, (timestamp, index) => {
|
|
752
874
|
const timestampString = timestamp.join();
|
|
753
|
-
const timestampBinary =
|
|
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(
|
|
881
|
+
const dbChange = deps.storage.readDbChange(ownerIdBytes, timestamp);
|
|
760
882
|
if (dbChange == null) {
|
|
761
|
-
|
|
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 (
|
|
781
|
-
return
|
|
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
|
|
916
|
+
return err(ProtocolErrorCode.SyncError);
|
|
795
917
|
}
|
|
796
|
-
return ok(
|
|
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
|
|
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(
|
|
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
|
-
: [
|
|
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
|
-
?
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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) => (
|
|
1141
|
+
export const encodeAndEncryptDbChange = (deps) => (message, key) => {
|
|
1037
1142
|
const buffer = createBuffer();
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1157
|
+
encodeString(buffer, column);
|
|
1046
1158
|
encodeSqliteValue(buffer, value);
|
|
1047
1159
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
|
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) => (
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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:
|
|
1176
|
-
DateIsoWithNegativeTime:
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
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
|
|
1201
|
-
if (
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
|
1212
|
-
if
|
|
1213
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1276
|
-
if (!
|
|
1277
|
-
throw new ProtocolDecodeError(
|
|
1278
|
-
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);
|
|
1279
1425
|
}
|
|
1280
1426
|
default:
|
|
1281
1427
|
throw new ProtocolDecodeError("invalid ProtocolValueType");
|