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