@evolu/common 6.0.1-preview.3 → 6.0.1-preview.31
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/dist/src/Array.d.ts +69 -5
- package/dist/src/Array.d.ts.map +1 -1
- package/dist/src/Array.js +64 -5
- package/dist/src/Assert.d.ts +6 -16
- package/dist/src/Assert.d.ts.map +1 -1
- package/dist/src/Assert.js +6 -18
- 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 +61 -34
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +32 -45
- package/dist/src/Evolu/Db.d.ts +161 -65
- package/dist/src/Evolu/Db.d.ts.map +1 -1
- package/dist/src/Evolu/Db.js +286 -694
- 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 +208 -133
- package/dist/src/Evolu/Evolu.d.ts.map +1 -1
- package/dist/src/Evolu/Evolu.js +188 -183
- 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 +277 -232
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +603 -378
- 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.js +3 -3
- package/dist/src/Evolu/Query.d.ts +2 -1
- package/dist/src/Evolu/Query.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.d.ts +92 -7
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +238 -76
- package/dist/src/Evolu/Schema.d.ts +129 -73
- package/dist/src/Evolu/Schema.d.ts.map +1 -1
- package/dist/src/Evolu/Schema.js +169 -89
- package/dist/src/Evolu/Storage.d.ts +240 -26
- package/dist/src/Evolu/Storage.d.ts.map +1 -1
- package/dist/src/Evolu/Storage.js +189 -91
- package/dist/src/Evolu/Sync.d.ts +67 -13
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +441 -20
- package/dist/src/Evolu/Timestamp.d.ts +85 -27
- package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
- package/dist/src/Evolu/Timestamp.js +77 -18
- 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/ManyToManyMap.d.ts +71 -10
- package/dist/src/ManyToManyMap.d.ts.map +1 -1
- package/dist/src/ManyToManyMap.js +41 -6
- 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/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/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 +63 -5
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +110 -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 +622 -340
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +666 -464
- 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 +7 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -2
- package/package.json +14 -13
- package/src/Array.ts +90 -11
- package/src/Assert.ts +6 -24
- 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 +97 -82
- package/src/Evolu/Db.ts +517 -1020
- package/src/Evolu/Diff.ts +7 -5
- package/src/Evolu/Evolu.ts +464 -355
- 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 +859 -676
- package/src/Evolu/Public.ts +7 -14
- package/src/Evolu/PublicKysely.ts +3 -3
- package/src/Evolu/Query.ts +2 -1
- package/src/Evolu/Relay.ts +437 -93
- package/src/Evolu/Schema.ts +391 -191
- package/src/Evolu/Storage.ts +532 -135
- package/src/Evolu/Sync.ts +766 -37
- package/src/Evolu/Timestamp.ts +88 -35
- package/src/Identicon.ts +197 -0
- package/src/Instances.ts +90 -0
- package/src/ManyToManyMap.ts +124 -24
- package/src/Number.ts +6 -10
- package/src/Platform.ts +26 -0
- package/src/Random.ts +3 -2
- package/src/Resources.ts +367 -0
- package/src/Result.ts +191 -54
- package/src/Skiplist.ts +1 -1
- package/src/Sqlite.ts +122 -17
- package/src/Task.ts +901 -0
- package/src/Time.ts +180 -5
- package/src/Type.ts +1084 -727
- package/src/Types.ts +1 -77
- package/src/WebSocket.ts +27 -25
- package/src/Worker.ts +72 -23
- package/src/index.ts +7 -2
- 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/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/NanoId.ts +0 -39
- package/src/Promise.ts +0 -295
|
@@ -3,33 +3,54 @@
|
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* ### Message structure
|
|
15
24
|
*
|
|
16
|
-
*
|
|
25
|
+
* | Field | Notes |
|
|
26
|
+
* | :----------------------------- | :------------------------ |
|
|
27
|
+
* | **Header** | |
|
|
28
|
+
* | - {@link protocolVersion} | |
|
|
29
|
+
* | - {@link OwnerId} | {@link Owner} |
|
|
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)** | |
|
|
36
|
+
* | - {@link ProtocolErrorCode} | |
|
|
37
|
+
* | **Broadcast (messageType=2)** | |
|
|
38
|
+
* | - (no additional fields) | |
|
|
39
|
+
* | **Messages** | |
|
|
40
|
+
* | - {@link NonNegativeInt} | A number of messages. |
|
|
41
|
+
* | - {@link EncryptedCrdtMessage} | |
|
|
42
|
+
* | **Ranges** | |
|
|
43
|
+
* | - {@link NonNegativeInt} | Number of ranges. |
|
|
44
|
+
* | - {@link Range} | |
|
|
17
45
|
*
|
|
18
|
-
*
|
|
19
|
-
* | :----------------------------- | :------------------------- |
|
|
20
|
-
* | **Header** | |
|
|
21
|
-
* | - {@link protocolVersion} | |
|
|
22
|
-
* | - {@link OwnerId} | |
|
|
23
|
-
* | - {@link ProtocolErrorCode} | In non-initiator response. |
|
|
24
|
-
* | **Messages** | |
|
|
25
|
-
* | - {@link NonNegativeInt} | A number of messages. |
|
|
26
|
-
* | - {@link EncryptedCrdtMessage} | |
|
|
27
|
-
* | - {@link WriteKey} | In initiator request. |
|
|
28
|
-
* | **Ranges** | |
|
|
29
|
-
* | - {@link NonNegativeInt} | Number of ranges. |
|
|
30
|
-
* | - {@link Range} | |
|
|
46
|
+
* ### WriteKey validation
|
|
31
47
|
*
|
|
32
|
-
*
|
|
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.
|
|
33
54
|
*
|
|
34
55
|
* ### Synchronization
|
|
35
56
|
*
|
|
@@ -43,38 +64,48 @@
|
|
|
43
64
|
* if further sync is needed or possible, continuing until both sides are
|
|
44
65
|
* synchronized.
|
|
45
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
|
+
*
|
|
46
71
|
* Both **Messages** and **Ranges** are optional, allowing each side to send,
|
|
47
72
|
* sync, or only subscribe data as needed.
|
|
48
73
|
*
|
|
49
|
-
* When the initiator sends data, the {@link
|
|
50
|
-
*
|
|
51
|
-
* responds without a {@link
|
|
52
|
-
* signals it wants data. If the non-initiator detects an issue, it
|
|
53
|
-
* error code via the `Error` field in the header back to the
|
|
54
|
-
* relay-to-relay or P2P sync, both sides may require the
|
|
55
|
-
* 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.
|
|
56
81
|
*
|
|
57
|
-
* ### Protocol
|
|
82
|
+
* ### Protocol errors
|
|
58
83
|
*
|
|
59
84
|
* The protocol uses error codes in the header to signal issues:
|
|
60
85
|
*
|
|
61
86
|
* - {@link ProtocolWriteKeyError}: The provided WriteKey is invalid or missing.
|
|
62
|
-
* - {@link ProtocolWriteError}: A write
|
|
63
|
-
*
|
|
64
|
-
* - {@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
|
|
65
90
|
* occurred.
|
|
66
|
-
* - {@link
|
|
91
|
+
* - {@link ProtocolVersionError}: Protocol version mismatch.
|
|
67
92
|
* - {@link ProtocolInvalidDataError}: The message is malformed or corrupted.
|
|
68
93
|
*
|
|
69
|
-
* All protocol errors except `ProtocolInvalidDataError` include the `
|
|
94
|
+
* All protocol errors except `ProtocolInvalidDataError` include the `OwnerId`
|
|
70
95
|
* to allow clients to associate errors with the correct owner.
|
|
71
96
|
*
|
|
72
|
-
* ### Message
|
|
97
|
+
* ### Message size limit
|
|
73
98
|
*
|
|
74
99
|
* The protocol enforces a strict maximum size for all messages, defined by
|
|
75
|
-
* {@link
|
|
76
|
-
* less than or equal to this limit,
|
|
77
|
-
*
|
|
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}.
|
|
78
109
|
*
|
|
79
110
|
* ### Why Binary?
|
|
80
111
|
*
|
|
@@ -82,13 +113,12 @@
|
|
|
82
113
|
*
|
|
83
114
|
* - Encrypted data doesn’t compress well, unlike plain JSON.
|
|
84
115
|
* - Message size must be controlled during creation.
|
|
85
|
-
* - Sequential byte reading is faster than parsing and
|
|
116
|
+
* - Sequential byte reading is faster than parsing and avoids conversions.
|
|
86
117
|
*
|
|
87
118
|
* It uses structure-aware encoding, significantly outperforming generic binary
|
|
88
119
|
* serialization formats with the following optimizations:
|
|
89
120
|
*
|
|
90
121
|
* - **NonNegativeInt:** Up to 33% smaller than MessagePack.
|
|
91
|
-
* - **Base64Url Strings:** Up to 25% size reduction.
|
|
92
122
|
* - **DateIso:** Up to 75% smaller.
|
|
93
123
|
* - **Timestamp Encoding:** Delta encoding for milliseconds and run-length
|
|
94
124
|
* encoding (RLE) for counters and NodeIds.
|
|
@@ -118,60 +148,139 @@
|
|
|
118
148
|
* Version negotiation is per-owner, allowing Evolu Protocol to evolve safely
|
|
119
149
|
* over time and provide clear feedback about version mismatches.
|
|
120
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
|
+
*
|
|
121
160
|
* @module
|
|
122
161
|
*/
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
* - ProtocolQuotaError should return storedBytes and actual quota.
|
|
173
|
+
*/
|
|
174
|
+
import { Packr } from "msgpackr";
|
|
125
175
|
import { isNonEmptyReadonlyArray } from "../Array.js";
|
|
126
176
|
import { assert } from "../Assert.js";
|
|
127
|
-
import {
|
|
177
|
+
import { bytesToHex, bytesToUtf8, createBuffer, hexToBytes, utf8ToBytes, } from "../Buffer.js";
|
|
128
178
|
import { padmePaddingLength, } from "../Crypto.js";
|
|
129
179
|
import { eqArrayNumber } from "../Eq.js";
|
|
130
180
|
import { computeBalancedBuckets } from "../Number.js";
|
|
131
181
|
import { objectToEntries } from "../Object.js";
|
|
132
182
|
import { err, ok } from "../Result.js";
|
|
133
|
-
import { Base64Url,
|
|
134
|
-
import {
|
|
135
|
-
import {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
183
|
+
import { Base64Url, base64UrlToUint8Array, between, DateIso, Id, idBytesToId, idBytesTypeValueLength, idToIdBytes, Int, Json, jsonToJsonValue, NonNegativeInt, Number, PositiveInt, uint8ArrayToBase64Url, } from "../Type.js";
|
|
184
|
+
import { ownerIdToOwnerIdBytes, ownerWriteKeyLength, } from "./Owner.js";
|
|
185
|
+
import { fingerprintSize, InfiniteUpperBound, RangeType, } from "./Storage.js";
|
|
186
|
+
import { Counter, eqTimestamp, Millis, timestampBytesLength, timestampBytesToTimestamp, timestampToTimestampBytes, } from "./Timestamp.js";
|
|
187
|
+
/**
|
|
188
|
+
* Evolu uses MessagePack for numbers and JSONs.
|
|
189
|
+
*
|
|
190
|
+
* - `variableMapSize: true` - More compact maps, ~5-10% slower encoding
|
|
191
|
+
* - `useRecords: false` - Standard MessagePack without extensions
|
|
192
|
+
*/
|
|
193
|
+
const packr = new Packr({ variableMapSize: true, useRecords: false });
|
|
194
|
+
const minProtocolMessageMaxSize = 1_000_000;
|
|
195
|
+
const maxProtocolMessageMaxSize = 100_000_000;
|
|
196
|
+
/**
|
|
197
|
+
* Protocol message maximum size.
|
|
198
|
+
*
|
|
199
|
+
* Defines the upper limit for how large a single protocol message can be.
|
|
200
|
+
* Implementations must enforce a maximum size between 1MB and 100MB to ensure
|
|
201
|
+
* compatibility across all Evolu implementations (the maximum size of mutation
|
|
202
|
+
* change is hardcoded and enforced hence the maximum size can't be smaller).
|
|
203
|
+
*
|
|
204
|
+
* Larger maximum sizes can be configured by relays to reduce roundtrips. For
|
|
205
|
+
* example, a dedicated relay with ample resources could configure a 100MB
|
|
206
|
+
* maximum to minimize roundtrips for large syncs.
|
|
207
|
+
*
|
|
208
|
+
* Only relays can safely configure larger sizes, as clients will handle them.
|
|
209
|
+
* Increasing this value on the client side would break compatibility with
|
|
210
|
+
* relays that enforce smaller limits.
|
|
211
|
+
*/
|
|
212
|
+
export const ProtocolMessageMaxSize = between(minProtocolMessageMaxSize, maxProtocolMessageMaxSize)(Int);
|
|
213
|
+
/**
|
|
214
|
+
* Default {@link ProtocolMessageMaxSize} (1MB).
|
|
215
|
+
*
|
|
216
|
+
* The standard size used across Evolu implementations. Relays with more
|
|
217
|
+
* resources can configure larger sizes to reduce roundtrips.
|
|
218
|
+
*/
|
|
219
|
+
export const defaultProtocolMessageMaxSize = minProtocolMessageMaxSize;
|
|
220
|
+
/**
|
|
221
|
+
* Protocol message ranges maximum size.
|
|
222
|
+
*
|
|
223
|
+
* Defines the upper limit for how large the ranges section of a protocol
|
|
224
|
+
* message can be. Implementations must enforce a maximum size between 3KB and
|
|
225
|
+
* 100KB to ensure compatibility.
|
|
226
|
+
*
|
|
227
|
+
* The upper bound is set to ensure ranges fit within the default 1MB
|
|
228
|
+
* {@link defaultProtocolMessageMaxSize}, maintaining compatibility between all
|
|
229
|
+
* clients and relays.
|
|
230
|
+
*/
|
|
231
|
+
export const ProtocolMessageRangesMaxSize = between(3_000, 100_000)(Int);
|
|
232
|
+
/**
|
|
233
|
+
* Default {@link ProtocolMessageRangesMaxSize} (30KB).
|
|
234
|
+
*
|
|
235
|
+
* The standard size used across Evolu implementations. Relays with more
|
|
236
|
+
* resources can configure larger sizes to reduce roundtrips.
|
|
237
|
+
*/
|
|
238
|
+
export const defaultProtocolMessageRangesMaxSize = 30_000;
|
|
140
239
|
/** Evolu Protocol version. */
|
|
141
|
-
export const protocolVersion = 0;
|
|
240
|
+
export const protocolVersion = NonNegativeInt.orThrow(0);
|
|
241
|
+
export const MessageType = {
|
|
242
|
+
/** Request message from initiator (client) to non-initiator (relay). */
|
|
243
|
+
Request: 0,
|
|
244
|
+
/** Response message from non-initiator (relay) to initiator (client). */
|
|
245
|
+
Response: 1,
|
|
246
|
+
/** Broadcast message from non-initiator (relay) to subscribed clients. */
|
|
247
|
+
Broadcast: 2,
|
|
248
|
+
};
|
|
249
|
+
export const SubscriptionFlags = {
|
|
250
|
+
/** No subscription changes for this owner. */
|
|
251
|
+
None: 0,
|
|
252
|
+
/** Subscribe to updates for this owner. */
|
|
253
|
+
Subscribe: 1,
|
|
254
|
+
/** Unsubscribe from updates for this owner. */
|
|
255
|
+
Unsubscribe: 2,
|
|
256
|
+
};
|
|
142
257
|
export const ProtocolErrorCode = {
|
|
143
258
|
NoError: 0,
|
|
144
259
|
/** A code for {@link ProtocolWriteKeyError}. */
|
|
145
260
|
WriteKeyError: 1,
|
|
146
261
|
/** A code for {@link ProtocolWriteError}. */
|
|
147
262
|
WriteError: 2,
|
|
263
|
+
/** A code for {@link ProtocolQuotaError}. */
|
|
264
|
+
QuotaError: 3,
|
|
148
265
|
/** A code for {@link ProtocolSyncError}. */
|
|
149
|
-
SyncError:
|
|
150
|
-
};
|
|
151
|
-
export const RangeType = {
|
|
152
|
-
Fingerprint: 1,
|
|
153
|
-
Skip: 0,
|
|
154
|
-
Timestamps: 2,
|
|
266
|
+
SyncError: 4,
|
|
155
267
|
};
|
|
156
|
-
export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
|
|
157
|
-
export const fingerprintSize = 12;
|
|
158
|
-
/** A fingerprint of an empty range. */
|
|
159
|
-
export const zeroFingerprint = new Uint8Array(fingerprintSize);
|
|
160
268
|
/**
|
|
161
269
|
* Creates a {@link ProtocolMessage} from CRDT messages.
|
|
162
270
|
*
|
|
163
|
-
* If the message size would exceed {@link
|
|
164
|
-
* ensures all messages will be sent in the next round(s) even over
|
|
271
|
+
* If the message size would exceed {@link defaultProtocolMessageMaxSize}, the
|
|
272
|
+
* protocol ensures all messages will be sent in the next round(s) even over
|
|
165
273
|
* unidirectional and stateless transports.
|
|
166
274
|
*/
|
|
167
275
|
export const createProtocolMessageFromCrdtMessages = (deps) => (owner, messages, maxSize) => {
|
|
168
276
|
const buffer = createProtocolMessageBuffer(owner.id, {
|
|
169
|
-
|
|
277
|
+
messageType: MessageType.Request,
|
|
278
|
+
totalMaxSize: maxSize ?? defaultProtocolMessageMaxSize,
|
|
170
279
|
writeKey: owner.writeKey,
|
|
171
280
|
});
|
|
172
281
|
let notAllMessagesSent = false;
|
|
173
282
|
for (const message of messages) {
|
|
174
|
-
const change = encodeAndEncryptDbChange(deps)(message
|
|
283
|
+
const change = encodeAndEncryptDbChange(deps)(message, owner.encryptionKey);
|
|
175
284
|
const encryptedCrdtMessage = { timestamp: message.timestamp, change };
|
|
176
285
|
if (buffer.canAddMessage(encryptedCrdtMessage)) {
|
|
177
286
|
buffer.addMessage(encryptedCrdtMessage);
|
|
@@ -190,14 +299,14 @@ export const createProtocolMessageFromCrdtMessages = (deps) => (owner, messages,
|
|
|
190
299
|
*
|
|
191
300
|
* The ideal approach would be to send three ranges (skip, fingerprint,
|
|
192
301
|
* skip) where the fingerprint of unsent messages would act as narrow sync
|
|
193
|
-
* probe. I think we can send
|
|
194
|
-
*
|
|
302
|
+
* probe. I think we can send `zeroFingerprint` which can be interpreted
|
|
303
|
+
* as an indication that the other side should reply with
|
|
195
304
|
* {@link TimestampsRange}, so no need to restart syncing.
|
|
196
305
|
*
|
|
197
306
|
* For now, using a random fingerprint avoids extra complexity and is good
|
|
198
307
|
* enough for this case.
|
|
199
308
|
*/
|
|
200
|
-
const randomFingerprint = deps.
|
|
309
|
+
const randomFingerprint = deps.randomBytes.create(fingerprintSize);
|
|
201
310
|
// There is always a space for Fingerprint with InfiniteUpperBound.
|
|
202
311
|
buffer.addRange({
|
|
203
312
|
type: RangeType.Fingerprint,
|
|
@@ -208,18 +317,25 @@ export const createProtocolMessageFromCrdtMessages = (deps) => (owner, messages,
|
|
|
208
317
|
return buffer.unwrap();
|
|
209
318
|
};
|
|
210
319
|
/** Creates a {@link ProtocolMessage} for sync. */
|
|
211
|
-
export const createProtocolMessageForSync = (deps) => (ownerId) => {
|
|
212
|
-
const buffer = createProtocolMessageBuffer(ownerId
|
|
213
|
-
|
|
214
|
-
|
|
320
|
+
export const createProtocolMessageForSync = (deps) => (ownerId, subscriptionFlag) => {
|
|
321
|
+
const buffer = createProtocolMessageBuffer(ownerId, {
|
|
322
|
+
messageType: MessageType.Request,
|
|
323
|
+
subscriptionFlag: subscriptionFlag ?? SubscriptionFlags.None,
|
|
324
|
+
});
|
|
325
|
+
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
326
|
+
const size = deps.storage.getSize(ownerIdBytes);
|
|
215
327
|
// Errors are handled by the storage.
|
|
216
328
|
if (size == null)
|
|
217
329
|
return null;
|
|
218
|
-
splitRange(deps)(
|
|
330
|
+
splitRange(deps)(ownerIdBytes, NonNegativeInt.orThrow(0), size, InfiniteUpperBound, buffer);
|
|
219
331
|
return buffer.unwrap();
|
|
220
332
|
};
|
|
221
|
-
export const
|
|
222
|
-
|
|
333
|
+
export const createProtocolMessageForUnsubscribe = (ownerId) => createProtocolMessageBuffer(ownerId, {
|
|
334
|
+
messageType: MessageType.Request,
|
|
335
|
+
subscriptionFlag: SubscriptionFlags.Unsubscribe,
|
|
336
|
+
}).unwrap();
|
|
337
|
+
export const createProtocolMessageBuffer = (ownerId, options) => {
|
|
338
|
+
const { totalMaxSize = defaultProtocolMessageMaxSize, rangesMaxSize = defaultProtocolMessageRangesMaxSize, version = protocolVersion, } = options;
|
|
223
339
|
const buffers = {
|
|
224
340
|
header: createBuffer(),
|
|
225
341
|
messages: {
|
|
@@ -233,18 +349,28 @@ export const createProtocolMessageBuffer = (ownerId, options = {}) => {
|
|
|
233
349
|
},
|
|
234
350
|
};
|
|
235
351
|
encodeNonNegativeInt(buffers.header, version);
|
|
236
|
-
buffers.header.extend(
|
|
237
|
-
|
|
238
|
-
|
|
352
|
+
buffers.header.extend(ownerIdToOwnerIdBytes(ownerId));
|
|
353
|
+
buffers.header.extend([options.messageType]);
|
|
354
|
+
if (options.messageType === MessageType.Request) {
|
|
355
|
+
if (!options.writeKey) {
|
|
356
|
+
buffers.header.extend([0]);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
buffers.header.extend([1]);
|
|
360
|
+
buffers.header.extend(options.writeKey);
|
|
361
|
+
}
|
|
362
|
+
const subscriptionFlag = options.subscriptionFlag ?? SubscriptionFlags.None;
|
|
363
|
+
buffers.header.extend([subscriptionFlag]);
|
|
364
|
+
}
|
|
365
|
+
else if (options.messageType === MessageType.Response) {
|
|
366
|
+
buffers.header.extend([options.errorCode]);
|
|
367
|
+
}
|
|
239
368
|
let isLastRangeInfinite = false;
|
|
240
369
|
const isWithinSizeLimits = () => getSize() <= totalMaxSize;
|
|
241
|
-
const getSize = () => (getHeaderAndMessagesSize() + getRangesSize());
|
|
370
|
+
const getSize = () => PositiveInt.orThrow(getHeaderAndMessagesSize() + getRangesSize());
|
|
242
371
|
const getHeaderAndMessagesSize = () => buffers.header.getLength() +
|
|
243
372
|
buffers.messages.timestamps.getLength() +
|
|
244
|
-
buffers.messages.dbChanges.getLength()
|
|
245
|
-
(buffers.messages.timestamps.getCount() > 0 && writeKey
|
|
246
|
-
? writeKeyLength
|
|
247
|
-
: 0);
|
|
373
|
+
buffers.messages.dbChanges.getLength();
|
|
248
374
|
const getRangesSize = () => buffers.ranges.timestamps.getCount() > 0
|
|
249
375
|
? buffers.ranges.timestamps.getLength() +
|
|
250
376
|
buffers.ranges.types.getLength() +
|
|
@@ -298,6 +424,7 @@ export const createProtocolMessageBuffer = (ownerId, options = {}) => {
|
|
|
298
424
|
: true));
|
|
299
425
|
},
|
|
300
426
|
addRange: (range) => {
|
|
427
|
+
assert(options.messageType !== MessageType.Broadcast, "Cannot add a range into broadcast message");
|
|
301
428
|
assert(!isLastRangeInfinite, "Cannot add a range after an InfiniteUpperBound range");
|
|
302
429
|
isLastRangeInfinite = range.upperBound === InfiniteUpperBound;
|
|
303
430
|
/**
|
|
@@ -306,11 +433,11 @@ export const createProtocolMessageBuffer = (ownerId, options = {}) => {
|
|
|
306
433
|
* use SkipRange.
|
|
307
434
|
*/
|
|
308
435
|
if (range.upperBound !== InfiniteUpperBound)
|
|
309
|
-
buffers.ranges.timestamps.add(
|
|
436
|
+
buffers.ranges.timestamps.add(timestampBytesToTimestamp(range.upperBound));
|
|
310
437
|
else {
|
|
311
438
|
buffers.ranges.timestamps.addInfinite();
|
|
312
439
|
}
|
|
313
|
-
encodeNonNegativeInt(buffers.ranges.types, range.type);
|
|
440
|
+
encodeNonNegativeInt(buffers.ranges.types, NonNegativeInt.orThrow(range.type));
|
|
314
441
|
switch (range.type) {
|
|
315
442
|
case RangeType.Skip:
|
|
316
443
|
break;
|
|
@@ -330,8 +457,6 @@ export const createProtocolMessageBuffer = (ownerId, options = {}) => {
|
|
|
330
457
|
}
|
|
331
458
|
buffers.messages.timestamps.append(buffers.header);
|
|
332
459
|
buffers.header.extend(buffers.messages.dbChanges.unwrap());
|
|
333
|
-
if (buffers.messages.timestamps.getCount() > 0 && writeKey)
|
|
334
|
-
buffers.header.extend(writeKey);
|
|
335
460
|
if (buffers.ranges.timestamps.getCount() > 0) {
|
|
336
461
|
buffers.ranges.timestamps.append(buffers.header);
|
|
337
462
|
buffers.header.extend(buffers.ranges.types.unwrap());
|
|
@@ -343,7 +468,7 @@ export const createProtocolMessageBuffer = (ownerId, options = {}) => {
|
|
|
343
468
|
};
|
|
344
469
|
};
|
|
345
470
|
export const createTimestampsBuffer = () => {
|
|
346
|
-
let count = 0;
|
|
471
|
+
let count = NonNegativeInt.orThrow(0);
|
|
347
472
|
const countBuffer = createBuffer();
|
|
348
473
|
const syncCount = () => {
|
|
349
474
|
countBuffer.reset();
|
|
@@ -388,9 +513,9 @@ export const createTimestampsBuffer = () => {
|
|
|
388
513
|
};
|
|
389
514
|
const createRunLengthEncoder = (encodeValue) => {
|
|
390
515
|
const buffer = createBuffer();
|
|
391
|
-
let previousLength = 0;
|
|
516
|
+
let previousLength = NonNegativeInt.orThrow(0);
|
|
392
517
|
let previousValue = null;
|
|
393
|
-
let runLength = 0;
|
|
518
|
+
let runLength = NonNegativeInt.orThrow(0);
|
|
394
519
|
return {
|
|
395
520
|
add: (value) => {
|
|
396
521
|
if (value === previousValue) {
|
|
@@ -399,7 +524,7 @@ const createRunLengthEncoder = (encodeValue) => {
|
|
|
399
524
|
}
|
|
400
525
|
else {
|
|
401
526
|
previousValue = value;
|
|
402
|
-
runLength = 1;
|
|
527
|
+
runLength = NonNegativeInt.orThrow(1);
|
|
403
528
|
}
|
|
404
529
|
previousLength = buffer.getLength();
|
|
405
530
|
encodeValue(buffer, value);
|
|
@@ -409,118 +534,235 @@ const createRunLengthEncoder = (encodeValue) => {
|
|
|
409
534
|
unwrap: () => buffer.unwrap(),
|
|
410
535
|
};
|
|
411
536
|
};
|
|
412
|
-
export const applyProtocolMessageAsClient = (deps) => (inputMessage,
|
|
413
|
-
|
|
414
|
-
|
|
537
|
+
export const applyProtocolMessageAsClient = (deps) => async (inputMessage, options = {}) => {
|
|
538
|
+
// try-catch instead of Result for performance and stacktraces
|
|
539
|
+
// DEV: Measure it again, I think we should use Result with new Error.
|
|
540
|
+
try {
|
|
541
|
+
const input = createBuffer(inputMessage);
|
|
542
|
+
const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
|
|
543
|
+
const version = options.version ?? protocolVersion;
|
|
544
|
+
if (requestedVersion !== version) {
|
|
545
|
+
return err({
|
|
546
|
+
type: "ProtocolVersionError",
|
|
547
|
+
version: requestedVersion,
|
|
548
|
+
isInitiator: version < requestedVersion,
|
|
549
|
+
ownerId,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const messageType = input.shift();
|
|
553
|
+
assert(messageType === MessageType.Response ||
|
|
554
|
+
messageType === MessageType.Broadcast, "Invalid MessageType");
|
|
555
|
+
if (messageType === MessageType.Response) {
|
|
556
|
+
const errorCode = input.shift();
|
|
557
|
+
if (errorCode !== ProtocolErrorCode.NoError) {
|
|
558
|
+
switch (errorCode) {
|
|
559
|
+
case ProtocolErrorCode.WriteKeyError:
|
|
560
|
+
return err({
|
|
561
|
+
type: "ProtocolWriteKeyError",
|
|
562
|
+
ownerId,
|
|
563
|
+
});
|
|
564
|
+
case ProtocolErrorCode.WriteError:
|
|
565
|
+
return err({
|
|
566
|
+
type: "ProtocolWriteError",
|
|
567
|
+
ownerId,
|
|
568
|
+
});
|
|
569
|
+
case ProtocolErrorCode.QuotaError:
|
|
570
|
+
return err({
|
|
571
|
+
type: "ProtocolQuotaError",
|
|
572
|
+
ownerId,
|
|
573
|
+
});
|
|
574
|
+
case ProtocolErrorCode.SyncError:
|
|
575
|
+
return err({
|
|
576
|
+
type: "ProtocolSyncError",
|
|
577
|
+
ownerId,
|
|
578
|
+
});
|
|
579
|
+
default:
|
|
580
|
+
throw new ProtocolDecodeError(`Invalid ProtocolErrorCode: ${errorCode}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const messages = decodeMessages(input);
|
|
585
|
+
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
586
|
+
if (isNonEmptyReadonlyArray(messages)) {
|
|
587
|
+
const writeResult = await deps.storage.writeMessages(ownerIdBytes, messages);
|
|
588
|
+
// Errors are handled by the Storage. Here we just stop syncing.
|
|
589
|
+
if (!writeResult.ok)
|
|
590
|
+
return ok({ type: "no-response" });
|
|
591
|
+
}
|
|
592
|
+
// Now: No writeKey, no sync.
|
|
593
|
+
// TODO: Allow to sync SharedReadonlyOwner
|
|
594
|
+
// Without local changes, writeKey will not be required.
|
|
595
|
+
// With local changes, writeKey will be required and if not provided,
|
|
596
|
+
// the sync will stop.
|
|
597
|
+
const writeKey = options.getWriteKey?.(ownerId);
|
|
598
|
+
if (writeKey == null) {
|
|
599
|
+
return ok({ type: "no-response" });
|
|
600
|
+
}
|
|
601
|
+
if (messageType === MessageType.Broadcast) {
|
|
602
|
+
return ok({ type: "broadcast" });
|
|
603
|
+
}
|
|
604
|
+
const ranges = decodeRanges(input);
|
|
605
|
+
if (!isNonEmptyReadonlyArray(ranges)) {
|
|
606
|
+
return ok({ type: "no-response" });
|
|
607
|
+
}
|
|
608
|
+
const output = createProtocolMessageBuffer(ownerId, {
|
|
609
|
+
messageType: MessageType.Request,
|
|
610
|
+
writeKey,
|
|
611
|
+
rangesMaxSize: options.rangesMaxSize,
|
|
612
|
+
});
|
|
613
|
+
const syncResult = sync(deps)(ranges, output, ownerIdBytes);
|
|
614
|
+
// Client sync error (handled via Storage) or no changes.
|
|
615
|
+
if (!syncResult.ok || !syncResult.value) {
|
|
616
|
+
return ok({ type: "no-response" });
|
|
617
|
+
}
|
|
618
|
+
return ok({ type: "response", message: output.unwrap() });
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
415
621
|
return err({
|
|
416
|
-
type: "
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
ownerId,
|
|
622
|
+
type: "ProtocolInvalidDataError",
|
|
623
|
+
data: inputMessage,
|
|
624
|
+
error,
|
|
420
625
|
});
|
|
421
626
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
627
|
+
};
|
|
628
|
+
export const applyProtocolMessageAsRelay = (deps) => async (inputMessage, options = {},
|
|
629
|
+
/** For tests only. */
|
|
630
|
+
version = protocolVersion) => {
|
|
631
|
+
// try-catch instead of Result for performance and stacktraces
|
|
632
|
+
// DEV: Measure it again, I think we should use Result with new Error.
|
|
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);
|
|
658
|
+
break;
|
|
659
|
+
case SubscriptionFlags.Unsubscribe:
|
|
660
|
+
options.unsubscribe?.(ownerId);
|
|
661
|
+
break;
|
|
662
|
+
case SubscriptionFlags.None:
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
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(),
|
|
430
674
|
});
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
675
|
+
}
|
|
676
|
+
}
|
|
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(),
|
|
435
686
|
});
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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,
|
|
440
722
|
});
|
|
441
|
-
|
|
442
|
-
|
|
723
|
+
for (const message of messages) {
|
|
724
|
+
broadcastBuffer.addMessage(message);
|
|
725
|
+
}
|
|
726
|
+
options.broadcast(ownerId, broadcastBuffer.unwrap());
|
|
727
|
+
}
|
|
443
728
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const output = createProtocolMessageBuffer(ownerId, {
|
|
456
|
-
writeKey,
|
|
457
|
-
totalMaxSize,
|
|
458
|
-
rangesMaxSize,
|
|
459
|
-
});
|
|
460
|
-
return sync(deps)("initiator", input, output, binaryOwnerId);
|
|
461
|
-
});
|
|
462
|
-
export const applyProtocolMessageAsRelay = (deps) => (inputMessage, { subscribe, broadcast, totalMaxSize, rangesMaxSize, } = {},
|
|
463
|
-
/** For testing purposes only; should not be used in production. */
|
|
464
|
-
version = protocolVersion) => tryDecodeProtocolData(inputMessage, (input) => {
|
|
465
|
-
const requestedVersion = decodeNonNegativeInt(input);
|
|
466
|
-
const ownerId = decodeOwnerId(input);
|
|
467
|
-
const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
|
|
468
|
-
if (requestedVersion !== version) {
|
|
469
|
-
// Non-initiator responds with its version and ownerId.
|
|
470
|
-
const output = createBuffer();
|
|
471
|
-
encodeNonNegativeInt(output, version);
|
|
472
|
-
output.extend(binaryOwnerId);
|
|
473
|
-
return ok(output.unwrap());
|
|
474
|
-
}
|
|
475
|
-
subscribe?.(ownerId);
|
|
476
|
-
const messages = decodeMessages(input);
|
|
477
|
-
if (isNonEmptyReadonlyArray(messages)) {
|
|
478
|
-
const messagesEnd = inputMessage.length - input.getLength();
|
|
479
|
-
const writeKey = input.shiftN(writeKeyLength);
|
|
480
|
-
const writeKeyIsValid = deps.storage.validateWriteKey(binaryOwnerId, writeKey);
|
|
481
|
-
if (!writeKeyIsValid)
|
|
482
|
-
return ok(createProtocolMessageBuffer(ownerId, {
|
|
483
|
-
errorCode: ProtocolErrorCode.WriteKeyError,
|
|
484
|
-
}).unwrap());
|
|
485
|
-
if (broadcast) {
|
|
486
|
-
// Instead of encoding a new protocol message, we reuse the inputMessage.
|
|
487
|
-
const broadcastMessage = concatBytes(inputMessage.slice(0, 17), new Uint8Array([ProtocolErrorCode.NoError]), inputMessage.slice(17, messagesEnd));
|
|
488
|
-
broadcast(ownerId, broadcastMessage);
|
|
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() });
|
|
489
740
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
});
|
|
500
|
-
return sync(deps)("non-initiator", input, output, binaryOwnerId);
|
|
501
|
-
});
|
|
502
|
-
/**
|
|
503
|
-
* Wraps Evolu Protocol decoding functions, which use exceptions instead of
|
|
504
|
-
* {@link Result} to provide stack traces for debugging and reduce allocation
|
|
505
|
-
* overhead in success cases.
|
|
506
|
-
*/
|
|
507
|
-
const tryDecodeProtocolData = (data, callback) => {
|
|
508
|
-
try {
|
|
509
|
-
return callback(createBuffer(data));
|
|
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 });
|
|
510
750
|
}
|
|
511
751
|
catch (error) {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
});
|
|
518
|
-
throw error;
|
|
752
|
+
return err({
|
|
753
|
+
type: "ProtocolInvalidDataError",
|
|
754
|
+
data: inputMessage,
|
|
755
|
+
error,
|
|
756
|
+
});
|
|
519
757
|
}
|
|
520
758
|
};
|
|
521
759
|
const decodeVersionAndOwner = (input) => {
|
|
760
|
+
// This structure must never change across protocol versions. The version
|
|
761
|
+
// and owner ID must always be the first two fields in every protocol message
|
|
762
|
+
// to enable version negotiation and owner identification before any other
|
|
763
|
+
// processing occurs.
|
|
522
764
|
const version = decodeNonNegativeInt(input);
|
|
523
|
-
const ownerId =
|
|
765
|
+
const ownerId = decodeId(input);
|
|
524
766
|
return [version, ownerId];
|
|
525
767
|
};
|
|
526
768
|
/**
|
|
@@ -544,29 +786,13 @@ const decodeMessages = (buffer) => {
|
|
|
544
786
|
}
|
|
545
787
|
return messages;
|
|
546
788
|
};
|
|
547
|
-
const sync = (deps) => (
|
|
548
|
-
const ranges = decodeRanges(input);
|
|
549
|
-
if (!isNonEmptyReadonlyArray(ranges)) {
|
|
550
|
-
// Nothing to sync.
|
|
551
|
-
return ok(null);
|
|
552
|
-
}
|
|
553
|
-
const binaryOwnerId = binaryOwnerIdToOwnerId(ownerId);
|
|
789
|
+
const sync = (deps) => (ranges, output, ownerIdBytes) => {
|
|
554
790
|
const outputInitialSize = output.getSize();
|
|
555
|
-
const
|
|
556
|
-
// Only the relay (non-initiator) reports sync errors, not the client (initiator).
|
|
557
|
-
if (role === "initiator") {
|
|
558
|
-
return ok(null);
|
|
559
|
-
}
|
|
560
|
-
const message = createProtocolMessageBuffer(binaryOwnerId, {
|
|
561
|
-
errorCode: ProtocolErrorCode.SyncError,
|
|
562
|
-
});
|
|
563
|
-
return ok(message.unwrap());
|
|
564
|
-
};
|
|
565
|
-
const storageSize = deps.storage.getSize(ownerId);
|
|
791
|
+
const storageSize = deps.storage.getSize(ownerIdBytes);
|
|
566
792
|
if (storageSize == null)
|
|
567
|
-
return
|
|
793
|
+
return err(ProtocolErrorCode.SyncError);
|
|
568
794
|
let prevUpperBound = null;
|
|
569
|
-
let prevIndex = 0;
|
|
795
|
+
let prevIndex = NonNegativeInt.orThrow(0);
|
|
570
796
|
let skip = false;
|
|
571
797
|
let nonSkipRangeAdded = false;
|
|
572
798
|
const skipRange = (range) => {
|
|
@@ -596,7 +822,7 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
596
822
|
};
|
|
597
823
|
// When we don't have a space...
|
|
598
824
|
const addFingerprintForRemainingRange = (begin) => {
|
|
599
|
-
const fingerprint = deps.storage.fingerprint(
|
|
825
|
+
const fingerprint = deps.storage.fingerprint(ownerIdBytes, begin, storageSize);
|
|
600
826
|
if (!fingerprint)
|
|
601
827
|
return false;
|
|
602
828
|
// There is always a space for a ramaining range.
|
|
@@ -610,30 +836,30 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
610
836
|
for (const range of ranges) {
|
|
611
837
|
const currentUpperBound = range.upperBound;
|
|
612
838
|
const lower = prevIndex;
|
|
613
|
-
let upper = deps.storage.findLowerBound(
|
|
839
|
+
let upper = deps.storage.findLowerBound(ownerIdBytes, prevIndex, storageSize, currentUpperBound);
|
|
614
840
|
if (upper == null)
|
|
615
|
-
return
|
|
841
|
+
return err(ProtocolErrorCode.SyncError);
|
|
616
842
|
switch (range.type) {
|
|
617
843
|
case RangeType.Skip: {
|
|
618
844
|
skipRange(range);
|
|
619
845
|
break;
|
|
620
846
|
}
|
|
621
847
|
case RangeType.Fingerprint: {
|
|
622
|
-
const ourFingerprint = deps.storage.fingerprint(
|
|
848
|
+
const ourFingerprint = deps.storage.fingerprint(ownerIdBytes, lower, upper);
|
|
623
849
|
if (ourFingerprint == null)
|
|
624
|
-
return
|
|
850
|
+
return err(ProtocolErrorCode.SyncError);
|
|
625
851
|
if (eqArrayNumber(range.fingerprint, ourFingerprint)) {
|
|
626
852
|
skipRange(range);
|
|
627
853
|
}
|
|
628
854
|
else {
|
|
629
855
|
if (output.canSplitRange()) {
|
|
630
856
|
coalesceSkipsBeforeAdd();
|
|
631
|
-
splitRange(deps)(
|
|
857
|
+
splitRange(deps)(ownerIdBytes, lower, upper, currentUpperBound, output);
|
|
632
858
|
}
|
|
633
859
|
else {
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
860
|
+
return addFingerprintForRemainingRange(upper)
|
|
861
|
+
? ok(true)
|
|
862
|
+
: err(ProtocolErrorCode.SyncError);
|
|
637
863
|
}
|
|
638
864
|
}
|
|
639
865
|
break;
|
|
@@ -642,19 +868,19 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
642
868
|
let endBound = currentUpperBound;
|
|
643
869
|
const timestampsWeNeed = new Map(range.timestamps.map((t) => [t.join(), true]));
|
|
644
870
|
const ourTimestamps = createTimestampsBuffer();
|
|
645
|
-
let
|
|
871
|
+
let cantReadDbChange = false;
|
|
646
872
|
let exceeded = false;
|
|
647
|
-
deps.storage.iterate(
|
|
873
|
+
deps.storage.iterate(ownerIdBytes, lower, upper, (timestamp, index) => {
|
|
648
874
|
const timestampString = timestamp.join();
|
|
649
|
-
const timestampBinary =
|
|
875
|
+
const timestampBinary = timestampBytesToTimestamp(timestamp);
|
|
650
876
|
let message = null;
|
|
651
877
|
if (timestampsWeNeed.has(timestampString)) {
|
|
652
878
|
timestampsWeNeed.delete(timestampString);
|
|
653
879
|
}
|
|
654
880
|
else {
|
|
655
|
-
const dbChange = deps.storage.readDbChange(
|
|
881
|
+
const dbChange = deps.storage.readDbChange(ownerIdBytes, timestamp);
|
|
656
882
|
if (dbChange == null) {
|
|
657
|
-
|
|
883
|
+
cantReadDbChange = true;
|
|
658
884
|
return false;
|
|
659
885
|
}
|
|
660
886
|
message = {
|
|
@@ -673,8 +899,8 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
673
899
|
output.addMessage(message);
|
|
674
900
|
return true;
|
|
675
901
|
});
|
|
676
|
-
if (
|
|
677
|
-
return
|
|
902
|
+
if (cantReadDbChange) {
|
|
903
|
+
return err(ProtocolErrorCode.SyncError);
|
|
678
904
|
}
|
|
679
905
|
const addRange = () => {
|
|
680
906
|
coalesceSkipsBeforeAdd();
|
|
@@ -687,9 +913,9 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
687
913
|
if (exceeded) {
|
|
688
914
|
addRange();
|
|
689
915
|
if (!addFingerprintForRemainingRange(upper)) {
|
|
690
|
-
return
|
|
916
|
+
return err(ProtocolErrorCode.SyncError);
|
|
691
917
|
}
|
|
692
|
-
return ok(
|
|
918
|
+
return ok(true);
|
|
693
919
|
}
|
|
694
920
|
// If we need something, we have to respond with our timestamps.
|
|
695
921
|
if (timestampsWeNeed.size > 0) {
|
|
@@ -706,10 +932,10 @@ const sync = (deps) => (role, input, output, ownerId) => {
|
|
|
706
932
|
}
|
|
707
933
|
// If all ranges were skipped, there are no changes and sync is complete.
|
|
708
934
|
const hasChange = output.getSize() > outputInitialSize;
|
|
709
|
-
return ok(hasChange
|
|
935
|
+
return ok(hasChange);
|
|
710
936
|
};
|
|
711
937
|
const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
|
|
712
|
-
const itemCount = (upper - lower);
|
|
938
|
+
const itemCount = NonNegativeInt.orThrow(upper - lower);
|
|
713
939
|
const buckets = computeBalancedBuckets(itemCount);
|
|
714
940
|
if (!buckets.ok) {
|
|
715
941
|
const range = {
|
|
@@ -717,8 +943,8 @@ const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
|
|
|
717
943
|
upperBound,
|
|
718
944
|
timestamps: createTimestampsBuffer(),
|
|
719
945
|
};
|
|
720
|
-
deps.storage.iterate(ownerId, 0, itemCount, (timestamp) => {
|
|
721
|
-
range.timestamps.add(
|
|
946
|
+
deps.storage.iterate(ownerId, NonNegativeInt.orThrow(0), itemCount, (timestamp) => {
|
|
947
|
+
range.timestamps.add(timestampBytesToTimestamp(timestamp));
|
|
722
948
|
return true;
|
|
723
949
|
});
|
|
724
950
|
buffer.addRange(range);
|
|
@@ -727,7 +953,10 @@ const splitRange = (deps) => (ownerId, lower, upper, upperBound, buffer) => {
|
|
|
727
953
|
// Check Storage.ts `fingerprint` and `fingerprintRanges` docs.
|
|
728
954
|
const fingerprintRangesBuckets = lower === 0
|
|
729
955
|
? buckets.value
|
|
730
|
-
: [
|
|
956
|
+
: [
|
|
957
|
+
lower,
|
|
958
|
+
...buckets.value.map((b) => NonNegativeInt.orThrow(b + lower)),
|
|
959
|
+
];
|
|
731
960
|
const fingerprintRanges = deps.storage.fingerprintRanges(ownerId, fingerprintRangesBuckets, upperBound);
|
|
732
961
|
// Errors are handled by the storage.
|
|
733
962
|
if (fingerprintRanges == null)
|
|
@@ -743,7 +972,7 @@ const decodeRanges = (buffer) => {
|
|
|
743
972
|
const rangesCount = decodeNonNegativeInt(buffer);
|
|
744
973
|
if (rangesCount === 0)
|
|
745
974
|
return [];
|
|
746
|
-
const timestampsCount = (rangesCount - 1);
|
|
975
|
+
const timestampsCount = NonNegativeInt.orThrow(rangesCount - 1);
|
|
747
976
|
const timestamps = decodeTimestamps(buffer, timestampsCount);
|
|
748
977
|
const rangeTypes = [];
|
|
749
978
|
for (let i = 0; i < rangesCount; i++) {
|
|
@@ -761,7 +990,7 @@ const decodeRanges = (buffer) => {
|
|
|
761
990
|
const ranges = [];
|
|
762
991
|
for (let i = 0; i < rangesCount; i++) {
|
|
763
992
|
const upperBound = i < timestampsCount
|
|
764
|
-
?
|
|
993
|
+
? timestampToTimestampBytes(timestamps[i])
|
|
765
994
|
: InfiniteUpperBound;
|
|
766
995
|
const rangeType = rangeTypes[i];
|
|
767
996
|
switch (rangeType) {
|
|
@@ -778,7 +1007,7 @@ const decodeRanges = (buffer) => {
|
|
|
778
1007
|
break;
|
|
779
1008
|
}
|
|
780
1009
|
case RangeType.Timestamps: {
|
|
781
|
-
const timestamps = decodeTimestamps(buffer).map(
|
|
1010
|
+
const timestamps = decodeTimestamps(buffer).map(timestampToTimestampBytes);
|
|
782
1011
|
ranges.push({
|
|
783
1012
|
type: RangeType.Timestamps,
|
|
784
1013
|
upperBound,
|
|
@@ -799,7 +1028,7 @@ const decodeTimestamps = (buffer, length) => {
|
|
|
799
1028
|
const deltaMillis = decodeNonNegativeInt(buffer);
|
|
800
1029
|
const millis = Millis.from(previousMillis + deltaMillis);
|
|
801
1030
|
if (!millis.ok)
|
|
802
|
-
throw new
|
|
1031
|
+
throw new ProtocolDecodeError(millis.error.type);
|
|
803
1032
|
millises.push(millis.value);
|
|
804
1033
|
previousMillis = millis.value;
|
|
805
1034
|
}
|
|
@@ -808,7 +1037,7 @@ const decodeTimestamps = (buffer, length) => {
|
|
|
808
1037
|
while (counterIndex < length) {
|
|
809
1038
|
const counter = Counter.from(decodeNonNegativeInt(buffer));
|
|
810
1039
|
if (!counter.ok)
|
|
811
|
-
throw new
|
|
1040
|
+
throw new ProtocolDecodeError(counter.error.type);
|
|
812
1041
|
const runLength = decodeNonNegativeInt(buffer);
|
|
813
1042
|
for (let i = 0; i < runLength; i++) {
|
|
814
1043
|
counters.push(counter.value);
|
|
@@ -835,84 +1064,21 @@ const decodeTimestamps = (buffer, length) => {
|
|
|
835
1064
|
}
|
|
836
1065
|
return timestamps;
|
|
837
1066
|
};
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
export const ownerIdToBinaryOwnerId = (ownerId) => base64Url256ToBytes(ownerId);
|
|
842
|
-
export const binaryOwnerIdToOwnerId = (binaryOwnerId) => decodeOwnerId(createBuffer(binaryOwnerId));
|
|
843
|
-
/**
|
|
844
|
-
* Base64Url string with maximum length of 256 characters. Encoding strings as
|
|
845
|
-
* Base64UrlString saves up to 25% in size compared to regular strings.
|
|
846
|
-
*/
|
|
847
|
-
export const Base64Url256 = maxLength(256)(Base64Url);
|
|
848
|
-
/**
|
|
849
|
-
* Alphabet used for Base64Url encoding. This is copied from the `nanoid`
|
|
850
|
-
* library to avoid dependency on a specific version of `nanoid`.
|
|
851
|
-
*/
|
|
852
|
-
const urlAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
|
|
853
|
-
/**
|
|
854
|
-
* Converts a Base64Url string to a Uint8Array for binary storage. This encoding
|
|
855
|
-
* is more space-efficient than UTF-8 for Base64Url strings.
|
|
856
|
-
*/
|
|
857
|
-
export const base64Url256ToBytes = (string) => {
|
|
858
|
-
const totalBits = string.length * 6; // 6 bits per character
|
|
859
|
-
const byteLength = Math.ceil(totalBits / 8);
|
|
860
|
-
const value = new globalThis.Uint8Array(byteLength);
|
|
861
|
-
let bitBuffer = 0;
|
|
862
|
-
let bitsInBuffer = 0;
|
|
863
|
-
let byteIndex = 0;
|
|
864
|
-
for (const char of string) {
|
|
865
|
-
const charValue = urlAlphabet.indexOf(char);
|
|
866
|
-
bitBuffer = (bitBuffer << 6) | charValue;
|
|
867
|
-
bitsInBuffer += 6;
|
|
868
|
-
while (bitsInBuffer >= 8) {
|
|
869
|
-
bitsInBuffer -= 8;
|
|
870
|
-
value[byteIndex++] = (bitBuffer >> bitsInBuffer) & 0xff;
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
if (bitsInBuffer > 0 && byteIndex < byteLength) {
|
|
874
|
-
value[byteIndex] = (bitBuffer << (8 - bitsInBuffer)) & 0xff;
|
|
875
|
-
}
|
|
876
|
-
return value;
|
|
877
|
-
};
|
|
878
|
-
export const decodeBase64Url256 = (buffer, stringLength) => {
|
|
879
|
-
const bytes = buffer.shiftN(Math.ceil((stringLength * 6) / 8));
|
|
880
|
-
let bitBuffer = 0;
|
|
881
|
-
let bitsInBuffer = 0;
|
|
882
|
-
let string = "";
|
|
883
|
-
for (const byte of bytes) {
|
|
884
|
-
bitBuffer = (bitBuffer << 8) | byte;
|
|
885
|
-
bitsInBuffer += 8;
|
|
886
|
-
while (bitsInBuffer >= 6) {
|
|
887
|
-
bitsInBuffer -= 6;
|
|
888
|
-
if (string.length < stringLength) {
|
|
889
|
-
const charValue = (bitBuffer >> bitsInBuffer) & 0x3f;
|
|
890
|
-
if (charValue < 0 || charValue >= urlAlphabet.length) {
|
|
891
|
-
throw new ProtocolDecodeError("invalid charValue");
|
|
892
|
-
}
|
|
893
|
-
string += urlAlphabet[charValue];
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
const result = Base64Url256.from(string);
|
|
898
|
-
if (!result.ok)
|
|
899
|
-
throw new ProtocolDecodeError(result.error.type);
|
|
900
|
-
return result.value;
|
|
1067
|
+
const decodeId = (buffer) => {
|
|
1068
|
+
const bytes = buffer.shiftN(idBytesTypeValueLength);
|
|
1069
|
+
return idBytesToId(bytes);
|
|
901
1070
|
};
|
|
902
|
-
const decodeId = (buffer) => decodeBase64Url256(buffer, idTypeValueLength);
|
|
903
|
-
/** Not all 16 bytes are valid {@link OwnerId}. */
|
|
904
|
-
const decodeOwnerId = (buffer) => decodeId(buffer);
|
|
905
1071
|
/**
|
|
906
1072
|
* Evolu uses MessagePack to handle all number variants except for
|
|
907
1073
|
* NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
|
|
908
1074
|
*/
|
|
909
1075
|
export const encodeNumber = (buffer, number) => {
|
|
910
|
-
buffer.extend(pack(number));
|
|
1076
|
+
buffer.extend(packr.pack(number));
|
|
911
1077
|
};
|
|
912
1078
|
export const decodeNumber = (buffer) => {
|
|
913
1079
|
let number;
|
|
914
1080
|
let end;
|
|
915
|
-
unpackMultiple(buffer.unwrap(), (n, _, e) => {
|
|
1081
|
+
packr.unpackMultiple(buffer.unwrap(), (n, _, e) => {
|
|
916
1082
|
number = n;
|
|
917
1083
|
end = e;
|
|
918
1084
|
return false;
|
|
@@ -926,24 +1092,30 @@ export const decodeNumber = (buffer) => {
|
|
|
926
1092
|
buffer.shiftN(endResult.value);
|
|
927
1093
|
return numberResult.value;
|
|
928
1094
|
};
|
|
929
|
-
export const binaryTimestampToFingerprint = (timestamp) => {
|
|
930
|
-
const hash = sha256(timestamp).slice(0, fingerprintSize);
|
|
931
|
-
return hash;
|
|
932
|
-
};
|
|
933
1095
|
/**
|
|
934
1096
|
* Encodes and encrypts a {@link DbChange} using the provided owner's encryption
|
|
935
1097
|
* key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
|
|
1098
|
+
*
|
|
1099
|
+
* The format includes the protocol version for backward compatibility and the
|
|
1100
|
+
* timestamp for tamper-proof verification that the timestamp matches the change
|
|
1101
|
+
* data.
|
|
936
1102
|
*/
|
|
937
|
-
export const encodeAndEncryptDbChange = (deps) => (
|
|
1103
|
+
export const encodeAndEncryptDbChange = (deps) => (message, key) => {
|
|
1104
|
+
const change = message.change;
|
|
938
1105
|
const buffer = createBuffer();
|
|
939
|
-
|
|
940
|
-
buffer
|
|
1106
|
+
// Encode protocol version first for backward compatibility
|
|
1107
|
+
encodeNonNegativeInt(buffer, protocolVersion);
|
|
1108
|
+
// Encode the timestamp (after version) for tamper verification
|
|
1109
|
+
const timestampBytes = timestampToTimestampBytes(message.timestamp);
|
|
1110
|
+
buffer.extend(timestampBytes);
|
|
1111
|
+
encodeString(buffer, change.table);
|
|
1112
|
+
buffer.extend(idToIdBytes(change.id));
|
|
941
1113
|
const entries = objectToEntries(change.values).map(([column, value]) => {
|
|
942
1114
|
return [column, value];
|
|
943
1115
|
});
|
|
944
1116
|
encodeLength(buffer, entries);
|
|
945
1117
|
for (const [column, value] of entries) {
|
|
946
|
-
|
|
1118
|
+
encodeString(buffer, column);
|
|
947
1119
|
encodeSqliteValue(buffer, value);
|
|
948
1120
|
}
|
|
949
1121
|
const paddingLength = padmePaddingLength(buffer.getLength());
|
|
@@ -957,30 +1129,55 @@ export const encodeAndEncryptDbChange = (deps) => (change, key) => {
|
|
|
957
1129
|
return buffer.unwrap();
|
|
958
1130
|
};
|
|
959
1131
|
/**
|
|
960
|
-
* Decrypts and decodes an {@link
|
|
961
|
-
* encryption key.
|
|
1132
|
+
* Decrypts and decodes an {@link EncryptedCrdtMessage} using the provided
|
|
1133
|
+
* owner's encryption key. Verifies that the embedded timestamp matches the
|
|
1134
|
+
* expected timestamp to ensure message integrity.
|
|
962
1135
|
*/
|
|
963
|
-
export const decryptAndDecodeDbChange = (deps) => (
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
|
|
1136
|
+
export const decryptAndDecodeDbChange = (deps) => (message, key) => {
|
|
1137
|
+
// try-catch instead of Result for performance and stacktraces
|
|
1138
|
+
try {
|
|
1139
|
+
const buffer = createBuffer(message.change);
|
|
1140
|
+
const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
|
|
1141
|
+
const ciphertextLength = decodeLength(buffer);
|
|
1142
|
+
const ciphertext = buffer.shiftN(ciphertextLength);
|
|
1143
|
+
const plaintextBytes = deps.symmetricCrypto.decrypt(ciphertext, key, nonce);
|
|
1144
|
+
if (!plaintextBytes.ok)
|
|
1145
|
+
return plaintextBytes;
|
|
1146
|
+
buffer.reset();
|
|
1147
|
+
buffer.extend(plaintextBytes.value);
|
|
1148
|
+
// Decode version (for future compatibility, no validation needed for now)
|
|
1149
|
+
decodeNonNegativeInt(buffer);
|
|
1150
|
+
// Decode and verify the embedded timestamp
|
|
1151
|
+
const embeddedTimestampBytes = buffer.shiftN(timestampBytesLength);
|
|
1152
|
+
const embeddedTimestamp = timestampBytesToTimestamp(embeddedTimestampBytes);
|
|
1153
|
+
// Verify timestamp integrity
|
|
1154
|
+
if (!eqTimestamp(embeddedTimestamp, message.timestamp)) {
|
|
1155
|
+
return err({
|
|
1156
|
+
type: "ProtocolTimestampMismatchError",
|
|
1157
|
+
expected: message.timestamp,
|
|
1158
|
+
embedded: embeddedTimestamp,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
const table = decodeString(buffer);
|
|
1162
|
+
const id = decodeId(buffer);
|
|
1163
|
+
const length = decodeLength(buffer);
|
|
1164
|
+
const values = Object.create(null);
|
|
1165
|
+
for (let i = 0; i < length; i++) {
|
|
1166
|
+
const column = decodeString(buffer);
|
|
1167
|
+
const value = decodeSqliteValue(buffer);
|
|
1168
|
+
values[column] = value;
|
|
1169
|
+
}
|
|
1170
|
+
const dbChange = { table, id, values };
|
|
1171
|
+
return ok(dbChange);
|
|
980
1172
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1173
|
+
catch (error) {
|
|
1174
|
+
return err({
|
|
1175
|
+
type: "ProtocolInvalidDataError",
|
|
1176
|
+
data: message.change,
|
|
1177
|
+
error,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
984
1181
|
/**
|
|
985
1182
|
* Encodes a non-negative integer into a variable-length integer format. It's
|
|
986
1183
|
* more efficient than encoding via {@link encodeNumber}.
|
|
@@ -1027,7 +1224,7 @@ export const decodeNonNegativeInt = (buffer) => {
|
|
|
1027
1224
|
return int.value;
|
|
1028
1225
|
};
|
|
1029
1226
|
export const encodeLength = (buffer, value) => {
|
|
1030
|
-
encodeNonNegativeInt(buffer, value.length);
|
|
1227
|
+
encodeNonNegativeInt(buffer, NonNegativeInt.orThrow(value.length));
|
|
1031
1228
|
};
|
|
1032
1229
|
export const decodeLength = decodeNonNegativeInt;
|
|
1033
1230
|
export const encodeString = (buffer, value) => {
|
|
@@ -1044,37 +1241,31 @@ export const encodeNodeId = (buffer, nodeId) => {
|
|
|
1044
1241
|
buffer.extend(hexToBytes(nodeId));
|
|
1045
1242
|
};
|
|
1046
1243
|
export const decodeNodeId = (buffer) => {
|
|
1047
|
-
const bytes = buffer.shiftN(8);
|
|
1244
|
+
const bytes = buffer.shiftN(NonNegativeInt.orThrow(8));
|
|
1048
1245
|
return bytesToHex(bytes);
|
|
1049
1246
|
};
|
|
1050
|
-
export const encodeBase64Url256 = (buffer, string) => {
|
|
1051
|
-
encodeLength(buffer, string);
|
|
1052
|
-
buffer.extend(base64Url256ToBytes(string));
|
|
1053
|
-
};
|
|
1054
|
-
export const decodeBase64Url256WithLength = (buffer) => {
|
|
1055
|
-
const length = decodeLength(buffer);
|
|
1056
|
-
return decodeBase64Url256(buffer, length);
|
|
1057
|
-
};
|
|
1058
1247
|
// Small ints are encoded into ProtocolValueType, saving one byte per int.
|
|
1059
1248
|
const isSmallInt = (value) => value >= 0 && value < 20;
|
|
1060
1249
|
export const ProtocolValueType = {
|
|
1061
1250
|
// 0-19 small ints
|
|
1062
1251
|
// SQLite types
|
|
1063
|
-
String: 20,
|
|
1064
|
-
Number: 21,
|
|
1065
|
-
Null: 22,
|
|
1066
|
-
|
|
1252
|
+
String: NonNegativeInt.orThrow(20),
|
|
1253
|
+
Number: NonNegativeInt.orThrow(21),
|
|
1254
|
+
Null: NonNegativeInt.orThrow(22),
|
|
1255
|
+
Bytes: NonNegativeInt.orThrow(23),
|
|
1067
1256
|
// We can add more types for other DBs or anything else later.
|
|
1068
1257
|
// Optimized types
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1258
|
+
NonNegativeInt: NonNegativeInt.orThrow(30),
|
|
1259
|
+
// String optimizations
|
|
1260
|
+
EmptyString: NonNegativeInt.orThrow(31), // 1 byte vs 2 bytes (50% reduction)
|
|
1261
|
+
Base64Url: NonNegativeInt.orThrow(32),
|
|
1262
|
+
Id: NonNegativeInt.orThrow(33),
|
|
1263
|
+
Json: NonNegativeInt.orThrow(34),
|
|
1073
1264
|
// new Date().toISOString() - 24 bytes
|
|
1074
1265
|
// encoded with fixed length - 8 bytes
|
|
1075
1266
|
// encode as NonNegativeInt - 6 bytes (additional 25% reduction)
|
|
1076
|
-
DateIsoWithNonNegativeTime:
|
|
1077
|
-
DateIsoWithNegativeTime:
|
|
1267
|
+
DateIsoWithNonNegativeTime: NonNegativeInt.orThrow(35),
|
|
1268
|
+
DateIsoWithNegativeTime: NonNegativeInt.orThrow(36), // 9 bytes
|
|
1078
1269
|
// TODO: Operations (from 40)
|
|
1079
1270
|
// Increment, Decrement, Patch, whatever.
|
|
1080
1271
|
};
|
|
@@ -1085,9 +1276,13 @@ export const encodeSqliteValue = (buffer, value) => {
|
|
|
1085
1276
|
}
|
|
1086
1277
|
switch (typeof value) {
|
|
1087
1278
|
case "string": {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1279
|
+
if (value === "") {
|
|
1280
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.EmptyString);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
const dateIso = DateIso.fromParent(value);
|
|
1284
|
+
if (dateIso.ok) {
|
|
1285
|
+
const time = new Date(dateIso.value).getTime();
|
|
1091
1286
|
if (NonNegativeInt.is(time)) {
|
|
1092
1287
|
encodeNonNegativeInt(buffer, ProtocolValueType.DateIsoWithNonNegativeTime);
|
|
1093
1288
|
encodeNonNegativeInt(buffer, time);
|
|
@@ -1098,25 +1293,31 @@ export const encodeSqliteValue = (buffer, value) => {
|
|
|
1098
1293
|
}
|
|
1099
1294
|
return;
|
|
1100
1295
|
}
|
|
1101
|
-
const
|
|
1102
|
-
if (
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
buffer.extend(base64Url256ToBytes(base64Url256.value));
|
|
1106
|
-
return;
|
|
1107
|
-
}
|
|
1108
|
-
encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url256);
|
|
1109
|
-
encodeBase64Url256(buffer, base64Url256.value);
|
|
1296
|
+
const id = Id.fromParent(value);
|
|
1297
|
+
if (id.ok) {
|
|
1298
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Id);
|
|
1299
|
+
buffer.extend(idToIdBytes(id.value));
|
|
1110
1300
|
return;
|
|
1111
1301
|
}
|
|
1112
|
-
const
|
|
1113
|
-
if
|
|
1114
|
-
|
|
1302
|
+
const json = Json.fromParent(value);
|
|
1303
|
+
// Only encode as Json if it survives JSON.parse/JSON.stringify round-trip.
|
|
1304
|
+
// Some valid JSON strings like "-0E0" get normalized to "0" during parsing,
|
|
1305
|
+
// which would cause data corruption if we don't verify round-trip safety.
|
|
1306
|
+
if (json.ok && JSON.stringify(jsonToJsonValue(json.value)) === value) {
|
|
1307
|
+
const jsonBytes = packr.pack(jsonToJsonValue(json.value));
|
|
1115
1308
|
encodeNonNegativeInt(buffer, ProtocolValueType.Json);
|
|
1116
1309
|
encodeLength(buffer, jsonBytes);
|
|
1117
1310
|
buffer.extend(jsonBytes);
|
|
1118
1311
|
return;
|
|
1119
1312
|
}
|
|
1313
|
+
const base64Url = Base64Url.fromParent(value);
|
|
1314
|
+
if (base64Url.ok) {
|
|
1315
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url);
|
|
1316
|
+
const bytes = base64UrlToUint8Array(base64Url.value);
|
|
1317
|
+
encodeLength(buffer, bytes);
|
|
1318
|
+
buffer.extend(bytes);
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1120
1321
|
encodeNonNegativeInt(buffer, ProtocolValueType.String);
|
|
1121
1322
|
encodeString(buffer, value);
|
|
1122
1323
|
return;
|
|
@@ -1136,7 +1337,7 @@ export const encodeSqliteValue = (buffer, value) => {
|
|
|
1136
1337
|
return;
|
|
1137
1338
|
}
|
|
1138
1339
|
}
|
|
1139
|
-
encodeNonNegativeInt(buffer, ProtocolValueType.
|
|
1340
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Bytes);
|
|
1140
1341
|
encodeLength(buffer, value);
|
|
1141
1342
|
buffer.extend(value);
|
|
1142
1343
|
};
|
|
@@ -1152,33 +1353,57 @@ export const decodeSqliteValue = (buffer) => {
|
|
|
1152
1353
|
return decodeNumber(buffer);
|
|
1153
1354
|
case ProtocolValueType.Null:
|
|
1154
1355
|
return null;
|
|
1155
|
-
case ProtocolValueType.
|
|
1356
|
+
case ProtocolValueType.Bytes: {
|
|
1156
1357
|
const length = decodeLength(buffer);
|
|
1157
1358
|
return buffer.shiftN(length);
|
|
1158
1359
|
}
|
|
1159
|
-
case ProtocolValueType.Id:
|
|
1360
|
+
case ProtocolValueType.Id:
|
|
1160
1361
|
return decodeId(buffer);
|
|
1161
|
-
}
|
|
1162
|
-
case ProtocolValueType.Base64Url256:
|
|
1163
|
-
return decodeBase64Url256WithLength(buffer);
|
|
1164
1362
|
case ProtocolValueType.NonNegativeInt:
|
|
1165
1363
|
return decodeNonNegativeInt(buffer);
|
|
1166
1364
|
case ProtocolValueType.Json: {
|
|
1167
1365
|
const length = decodeLength(buffer);
|
|
1168
1366
|
const bytes = buffer.shiftN(length);
|
|
1169
|
-
return JSON.stringify(unpack(bytes));
|
|
1367
|
+
return JSON.stringify(packr.unpack(bytes));
|
|
1170
1368
|
}
|
|
1171
1369
|
case ProtocolValueType.DateIsoWithNonNegativeTime:
|
|
1172
1370
|
case ProtocolValueType.DateIsoWithNegativeTime: {
|
|
1173
1371
|
const time = type === ProtocolValueType.DateIsoWithNonNegativeTime
|
|
1174
1372
|
? decodeNonNegativeInt(buffer)
|
|
1175
1373
|
: decodeNumber(buffer);
|
|
1176
|
-
const
|
|
1177
|
-
if (!
|
|
1178
|
-
throw new ProtocolDecodeError(
|
|
1179
|
-
return
|
|
1374
|
+
const dateIso = DateIso.fromParent(new Date(time).toISOString());
|
|
1375
|
+
if (!dateIso.ok)
|
|
1376
|
+
throw new ProtocolDecodeError(dateIso.error.type);
|
|
1377
|
+
return dateIso.value;
|
|
1378
|
+
}
|
|
1379
|
+
case ProtocolValueType.EmptyString:
|
|
1380
|
+
return "";
|
|
1381
|
+
case ProtocolValueType.Base64Url: {
|
|
1382
|
+
const length = decodeLength(buffer);
|
|
1383
|
+
const bytes = buffer.shiftN(length);
|
|
1384
|
+
return uint8ArrayToBase64Url(bytes);
|
|
1180
1385
|
}
|
|
1181
1386
|
default:
|
|
1182
1387
|
throw new ProtocolDecodeError("invalid ProtocolValueType");
|
|
1183
1388
|
}
|
|
1184
1389
|
};
|
|
1390
|
+
/**
|
|
1391
|
+
* Decodes a ProtocolMessage into a readable JSON object for debugging.
|
|
1392
|
+
*
|
|
1393
|
+
* Note: This is a stub for future implementation. It should use:
|
|
1394
|
+
*
|
|
1395
|
+
* - DecodeVersionAndOwner
|
|
1396
|
+
* - DecodeError or decodeWriteKeys (depending on context)
|
|
1397
|
+
* - DecodeMessages
|
|
1398
|
+
* - DecodeRanges
|
|
1399
|
+
*
|
|
1400
|
+
* If you want to help, please contribute to this function.
|
|
1401
|
+
*/
|
|
1402
|
+
export const decodeProtocolMessageToJson = (_protocolMessage, _isInitiator) => {
|
|
1403
|
+
// TODO: Implement using
|
|
1404
|
+
// - decodeVersionAndOwner
|
|
1405
|
+
// -- decodeError or decodeWriteKeys (should be refactored out),
|
|
1406
|
+
// -- decodeMessages, and decodeRanges.
|
|
1407
|
+
// This is a stub for PRs and community contributions.
|
|
1408
|
+
throw new Error("decodeProtocolMessageToJson is not implemented yet.");
|
|
1409
|
+
};
|