@evolu/common 5.4.7 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -34
- package/dist/src/Array.d.ts +17 -0
- package/dist/src/Array.d.ts.map +1 -0
- package/dist/src/Array.js +12 -0
- package/dist/src/Assert.d.ts +68 -0
- package/dist/src/Assert.d.ts.map +1 -0
- package/dist/src/Assert.js +77 -0
- package/dist/src/BigInt.d.ts +20 -0
- package/dist/src/BigInt.d.ts.map +1 -0
- package/dist/src/BigInt.js +18 -0
- package/dist/src/Buffer.d.ts +92 -0
- package/dist/src/Buffer.d.ts.map +1 -0
- package/dist/src/Buffer.js +62 -0
- package/dist/src/Callbacks.d.ts +20 -0
- package/dist/src/Callbacks.d.ts.map +1 -0
- package/dist/src/Callbacks.js +18 -0
- package/dist/src/Console.d.ts +78 -0
- package/dist/src/Console.d.ts.map +1 -0
- package/dist/src/Console.js +103 -0
- package/dist/src/Crypto.d.ts +72 -39
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +89 -54
- package/dist/src/Eq.d.ts +97 -0
- package/dist/src/Eq.d.ts.map +1 -0
- package/dist/src/Eq.js +167 -0
- package/dist/src/Error.d.ts +14 -10
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +43 -11
- package/dist/src/Evolu/Config.d.ts +69 -0
- package/dist/src/Evolu/Config.d.ts.map +1 -0
- package/dist/src/Evolu/Config.js +9 -0
- package/dist/src/Evolu/Db.d.ts +126 -0
- package/dist/src/Evolu/Db.d.ts.map +1 -0
- package/dist/src/Evolu/Db.js +774 -0
- package/dist/src/Evolu/Diff.d.ts +43 -0
- package/dist/src/Evolu/Diff.d.ts.map +1 -0
- package/dist/src/Evolu/Diff.js +95 -0
- package/dist/src/Evolu/Evolu.d.ts +334 -0
- package/dist/src/Evolu/Evolu.d.ts.map +1 -0
- package/dist/src/Evolu/Evolu.js +434 -0
- package/dist/src/Evolu/Internal.d.ts +26 -0
- package/dist/src/Evolu/Internal.d.ts.map +1 -0
- package/dist/src/Evolu/Internal.js +25 -0
- package/dist/src/Evolu/Kysely.d.ts +6 -0
- package/dist/src/Evolu/Kysely.d.ts.map +1 -0
- package/dist/src/Evolu/Kysely.js +21 -0
- package/dist/src/Evolu/Owner.d.ts +155 -0
- package/dist/src/Evolu/Owner.d.ts.map +1 -0
- package/dist/src/Evolu/Owner.js +126 -0
- package/dist/src/Evolu/Platform.d.ts +23 -0
- package/dist/src/Evolu/Platform.d.ts.map +1 -0
- package/dist/src/Evolu/Platform.js +1 -0
- package/dist/src/Evolu/Protocol.d.ts +401 -0
- package/dist/src/Evolu/Protocol.d.ts.map +1 -0
- package/dist/src/Evolu/Protocol.js +1151 -0
- package/dist/src/Evolu/Public.d.ts +18 -0
- package/dist/src/Evolu/Public.d.ts.map +1 -0
- package/dist/src/Evolu/Public.js +11 -0
- package/dist/src/Evolu/PublicKysely.d.ts +148 -0
- package/dist/src/Evolu/PublicKysely.d.ts.map +1 -0
- package/dist/src/Evolu/PublicKysely.js +185 -0
- package/dist/src/Evolu/Query.d.ts +63 -0
- package/dist/src/Evolu/Query.d.ts.map +1 -0
- package/dist/src/Evolu/Query.js +61 -0
- package/dist/src/Evolu/Relay.d.ts +13 -0
- package/dist/src/Evolu/Relay.d.ts.map +1 -0
- package/dist/src/Evolu/Relay.js +109 -0
- package/dist/src/Evolu/Schema.d.ts +201 -0
- package/dist/src/Evolu/Schema.d.ts.map +1 -0
- package/dist/src/Evolu/Schema.js +150 -0
- package/dist/src/Evolu/Storage.d.ts +49 -0
- package/dist/src/Evolu/Storage.d.ts.map +1 -0
- package/dist/src/Evolu/Storage.js +1111 -0
- package/dist/src/Evolu/Sync.d.ts +59 -0
- package/dist/src/Evolu/Sync.d.ts.map +1 -0
- package/dist/src/Evolu/Sync.js +29 -0
- package/dist/src/Evolu/Timestamp.d.ts +106 -0
- package/dist/src/Evolu/Timestamp.d.ts.map +1 -0
- package/dist/src/Evolu/Timestamp.js +179 -0
- package/dist/src/Function.d.ts +54 -0
- package/dist/src/Function.d.ts.map +1 -0
- package/dist/src/Function.js +38 -0
- package/dist/src/ManyToManyMap.d.ts +26 -0
- package/dist/src/ManyToManyMap.d.ts.map +1 -0
- package/dist/src/ManyToManyMap.js +92 -0
- package/dist/src/NanoId.d.ts +27 -0
- package/dist/src/NanoId.d.ts.map +1 -0
- package/dist/src/NanoId.js +6 -0
- package/dist/src/Number.d.ts +42 -0
- package/dist/src/Number.d.ts.map +1 -0
- package/dist/src/Number.js +55 -0
- package/dist/src/Object.d.ts +35 -0
- package/dist/src/Object.d.ts.map +1 -0
- package/dist/src/Object.js +36 -0
- package/dist/src/Order.d.ts +90 -0
- package/dist/src/Order.d.ts.map +1 -0
- package/dist/src/Order.js +85 -0
- package/dist/src/Promise.d.ts +180 -0
- package/dist/src/Promise.d.ts.map +1 -0
- package/dist/src/Promise.js +176 -0
- package/dist/src/Random.d.ts +52 -0
- package/dist/src/Random.d.ts.map +1 -0
- package/dist/src/Random.js +29 -0
- package/dist/src/Ref.d.ts +40 -0
- package/dist/src/Ref.d.ts.map +1 -0
- package/dist/src/Ref.js +13 -0
- package/dist/src/Result.d.ts +421 -0
- package/dist/src/Result.d.ts.map +1 -0
- package/dist/src/Result.js +357 -0
- package/dist/src/Skiplist.d.ts +23 -0
- package/dist/src/Skiplist.d.ts.map +1 -0
- package/dist/src/Skiplist.js +58 -0
- package/dist/src/Sqlite.d.ts +116 -52
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +183 -67
- package/dist/src/Store.d.ts +45 -8
- package/dist/src/Store.d.ts.map +1 -1
- package/dist/src/Store.js +33 -17
- package/dist/src/String.d.ts +2 -0
- package/dist/src/String.d.ts.map +1 -0
- package/dist/src/String.js +14 -0
- package/dist/src/Time.d.ts +20 -0
- package/dist/src/Time.d.ts.map +1 -0
- package/dist/src/Time.js +25 -0
- package/dist/src/Type.d.ts +1937 -0
- package/dist/src/Type.d.ts.map +1 -0
- package/dist/src/Type.js +2002 -0
- package/dist/src/Types.d.ts +188 -0
- package/dist/src/Types.d.ts.map +1 -0
- package/dist/src/Types.js +6 -0
- package/dist/src/WebSocket.d.ts +112 -0
- package/dist/src/WebSocket.d.ts.map +1 -0
- package/dist/src/WebSocket.js +139 -0
- package/dist/src/Worker.d.ts +44 -0
- package/dist/src/Worker.d.ts.map +1 -0
- package/dist/src/Worker.js +66 -0
- package/dist/src/index.d.ts +24 -11
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +24 -11
- package/package.json +29 -38
- package/src/Array.ts +39 -0
- package/src/Assert.ts +116 -0
- package/src/BigInt.ts +29 -0
- package/src/Buffer.ts +175 -0
- package/src/Callbacks.ts +43 -0
- package/src/Console.ts +159 -0
- package/src/Crypto.ts +169 -115
- package/src/Eq.ts +204 -0
- package/src/Error.ts +57 -20
- package/src/Evolu/Config.ts +83 -0
- package/src/Evolu/Db.ts +1275 -0
- package/src/Evolu/Diff.ts +142 -0
- package/src/Evolu/Evolu.ts +947 -0
- package/src/Evolu/Internal.ts +26 -0
- package/src/Evolu/Kysely.ts +38 -0
- package/src/Evolu/Owner.ts +296 -0
- package/src/Evolu/Platform.ts +27 -0
- package/src/Evolu/Protocol.ts +1857 -0
- package/src/Evolu/Public.ts +43 -0
- package/src/Evolu/PublicKysely.ts +240 -0
- package/src/Evolu/Query.ts +167 -0
- package/src/Evolu/Relay.ts +142 -0
- package/src/Evolu/Schema.ts +417 -0
- package/src/Evolu/Storage.ts +1281 -0
- package/src/Evolu/Sync.ts +105 -0
- package/src/Evolu/Timestamp.ts +311 -0
- package/src/Function.ts +58 -0
- package/src/ManyToManyMap.ts +140 -0
- package/src/NanoId.ts +39 -0
- package/src/Number.ts +90 -0
- package/src/Object.ts +64 -0
- package/src/Order.ts +113 -0
- package/src/Promise.ts +295 -0
- package/src/Random.ts +68 -0
- package/src/Ref.ts +63 -0
- package/src/Result.ts +453 -0
- package/src/Skiplist.ts +102 -0
- package/src/Sqlite.ts +366 -153
- package/src/Store.ts +79 -36
- package/src/String.ts +10 -0
- package/src/Time.ts +36 -0
- package/src/Type.ts +3978 -0
- package/src/Types.ts +209 -0
- package/src/WebSocket.ts +273 -0
- package/src/Worker.ts +129 -0
- package/src/index.ts +24 -11
- package/dist/src/Config.d.ts +0 -56
- package/dist/src/Config.d.ts.map +0 -1
- package/dist/src/Config.js +0 -39
- package/dist/src/Crdt.d.ts +0 -89
- package/dist/src/Crdt.d.ts.map +0 -1
- package/dist/src/Crdt.js +0 -181
- package/dist/src/Db.d.ts +0 -107
- package/dist/src/Db.d.ts.map +0 -1
- package/dist/src/Db.js +0 -443
- package/dist/src/Diff.d.ts +0 -27
- package/dist/src/Diff.d.ts.map +0 -1
- package/dist/src/Diff.js +0 -84
- package/dist/src/Evolu.d.ts +0 -426
- package/dist/src/Evolu.d.ts.map +0 -1
- package/dist/src/Evolu.js +0 -333
- package/dist/src/Model.d.ts +0 -141
- package/dist/src/Model.d.ts.map +0 -1
- package/dist/src/Model.js +0 -125
- package/dist/src/Murmurhash.d.ts +0 -2
- package/dist/src/Murmurhash.d.ts.map +0 -1
- package/dist/src/Murmurhash.js +0 -60
- package/dist/src/Owner.d.ts +0 -33
- package/dist/src/Owner.d.ts.map +0 -1
- package/dist/src/Owner.js +0 -26
- package/dist/src/Platform.d.ts +0 -37
- package/dist/src/Platform.d.ts.map +0 -1
- package/dist/src/Platform.js +0 -11
- package/dist/src/Protobuf.d.ts +0 -81
- package/dist/src/Protobuf.d.ts.map +0 -1
- package/dist/src/Protobuf.js +0 -92
- package/dist/src/Public.d.ts +0 -13
- package/dist/src/Public.d.ts.map +0 -1
- package/dist/src/Public.js +0 -6
- package/dist/src/Socket.d.ts +0 -8
- package/dist/src/Socket.d.ts.map +0 -1
- package/dist/src/Socket.js +0 -51
- package/dist/src/Sql.d.ts +0 -12
- package/dist/src/Sql.d.ts.map +0 -1
- package/dist/src/Sql.js +0 -30
- package/dist/src/Sync.d.ts +0 -70
- package/dist/src/Sync.d.ts.map +0 -1
- package/dist/src/Sync.js +0 -127
- package/src/Config.ts +0 -119
- package/src/Crdt.ts +0 -361
- package/src/Db.ts +0 -955
- package/src/Diff.ts +0 -114
- package/src/Evolu.ts +0 -1016
- package/src/Model.ts +0 -233
- package/src/Murmurhash.ts +0 -70
- package/src/Owner.ts +0 -69
- package/src/Platform.ts +0 -47
- package/src/Protobuf.ts +0 -155
- package/src/Public.ts +0 -12
- package/src/Socket.ts +0 -83
- package/src/Sql.ts +0 -41
- package/src/Sync.ts +0 -315
|
@@ -0,0 +1,1857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evolu Protocol
|
|
3
|
+
*
|
|
4
|
+
* Evolu Protocol is a local-first, end-to-end encrypted binary synchronization
|
|
5
|
+
* protocol optimized for minimal size and maximum speed. It enables data sync
|
|
6
|
+
* between a client and a relay, clients in a peer-to-peer (P2P) setup, or
|
|
7
|
+
* relays with each other.
|
|
8
|
+
*
|
|
9
|
+
* Evolu Protocol is designed for SQLite but can be extended to any database. It
|
|
10
|
+
* implements [Range-Based Set Reconciliation](https://arxiv.org/abs/2212.13567)
|
|
11
|
+
* by Aljoscha Meyer.
|
|
12
|
+
*
|
|
13
|
+
* To learn how RBSR works, check
|
|
14
|
+
* [Negentropy](https://logperiodic.com/rbsr.html). Evolu Protocol is similar to
|
|
15
|
+
* Negentropy but uses different encoding and also provides data transfer and
|
|
16
|
+
* ownership.
|
|
17
|
+
*
|
|
18
|
+
* ### Message Structure
|
|
19
|
+
*
|
|
20
|
+
* | Field | Notes |
|
|
21
|
+
* | :----------------------------- | :------------------------- |
|
|
22
|
+
* | **Header** | |
|
|
23
|
+
* | - {@link protocolVersion} | |
|
|
24
|
+
* | - {@link OwnerId} | |
|
|
25
|
+
* | - {@link ProtocolErrorCode} | In non-initiator response. |
|
|
26
|
+
* | **Messages** | |
|
|
27
|
+
* | - {@link NonNegativeInt} | A number of messages. |
|
|
28
|
+
* | - {@link EncryptedCrdtMessage} | |
|
|
29
|
+
* | - {@link WriteKey} | In initiator request. |
|
|
30
|
+
* | **Ranges** | |
|
|
31
|
+
* | - {@link NonNegativeInt} | Number of ranges. |
|
|
32
|
+
* | - {@link Range} | |
|
|
33
|
+
*
|
|
34
|
+
* Every protocol message belongs to an owner.
|
|
35
|
+
*
|
|
36
|
+
* ### Synchronization
|
|
37
|
+
*
|
|
38
|
+
* - **Messages**: Sends {@link EncryptedCrdtMessage}s in either direction.
|
|
39
|
+
* - **Ranges**: Determines messages to sync. Usage varies by transport—e.g., sent
|
|
40
|
+
* only on WebSocket connection open or with every fetch request.
|
|
41
|
+
*
|
|
42
|
+
* Synchronization involves an initiator and a non-initiator. The **initiator**
|
|
43
|
+
* is typically a client, and the **non-initiator** is typically a relay. Each
|
|
44
|
+
* side processes the received message and responds with a new `ProtocolMessage`
|
|
45
|
+
* if further sync is needed or possible, continuing until both sides are
|
|
46
|
+
* synchronized.
|
|
47
|
+
*
|
|
48
|
+
* Both **Messages** and **Ranges** are optional, allowing each side to send,
|
|
49
|
+
* sync, or only subscribe data as needed.
|
|
50
|
+
*
|
|
51
|
+
* When the initiator sends data, the {@link WriteKey} is **required** in
|
|
52
|
+
* **Messages** as a secure token proving the initiator can write changes. The
|
|
53
|
+
* non-initiator responds without a {@link WriteKey}, since the initiator’s
|
|
54
|
+
* request already signals it wants data. If the non-initiator detects an issue
|
|
55
|
+
* (e.g., an invalid {@link WriteKey} causing a {@link ProtocolWriteKeyError}, or
|
|
56
|
+
* a write failure causing a {@link ProtocolWriteError}), it sends an error code
|
|
57
|
+
* via the `Error` field in the header back to the initiator. In relay-to-relay
|
|
58
|
+
* or P2P sync, both sides may require the {@link WriteKey} depending on who is
|
|
59
|
+
* the initiator.
|
|
60
|
+
*
|
|
61
|
+
* ### Message Size Limit
|
|
62
|
+
*
|
|
63
|
+
* The protocol enforces a strict maximum size for all messages, defined by
|
|
64
|
+
* {@link maxProtocolMessageSize}. This ensures every `ProtocolMessage` is less
|
|
65
|
+
* than or equal to this limit, eliminating the need for applications to
|
|
66
|
+
* fragment and reconstruct messages during transmission.
|
|
67
|
+
*
|
|
68
|
+
* ### Why Binary?
|
|
69
|
+
*
|
|
70
|
+
* The protocol avoids JSON because:
|
|
71
|
+
*
|
|
72
|
+
* - Encrypted data doesn’t compress well, unlike plain JSON.
|
|
73
|
+
* - Message size must be controlled during creation.
|
|
74
|
+
* - Sequential byte reading is faster than parsing and can avoid conversions.
|
|
75
|
+
*
|
|
76
|
+
* It uses structure-aware encoding, significantly outperforming generic binary
|
|
77
|
+
* serialization formats with the following optimizations:
|
|
78
|
+
*
|
|
79
|
+
* - **NonNegativeInt:** Up to 33% smaller than MessagePack.
|
|
80
|
+
* - **Base64Url Strings:** Up to 25% size reduction.
|
|
81
|
+
* - **DateIso:** Up to 75% smaller.
|
|
82
|
+
* - **Timestamp Encoding:** Delta encoding for milliseconds and run-length
|
|
83
|
+
* encoding (RLE) for counters and NodeIds.
|
|
84
|
+
* - **Small Integers (0 to 19):** Reduces size by 1 byte per integer.
|
|
85
|
+
*
|
|
86
|
+
* To avoid reinventing serialization where it’s unnecessary—like for JSON and
|
|
87
|
+
* certain numbers—the Evolu Protocol relies on MessagePack.
|
|
88
|
+
*
|
|
89
|
+
* ### Versioning
|
|
90
|
+
*
|
|
91
|
+
* The initiator sends a versioned `ProtocolMessage`. If the non-initiator uses
|
|
92
|
+
* a different version, it responds with a message containing only its protocol
|
|
93
|
+
* version—**without an `ownerId`**. This allows the initiator to check protocol
|
|
94
|
+
* compatibility, for example, by sending version-only messages to multiple
|
|
95
|
+
* relays before starting synchronization.
|
|
96
|
+
*
|
|
97
|
+
* @module
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
101
|
+
import { pack, unpack, unpackMultiple } from "msgpackr";
|
|
102
|
+
import { isNonEmptyReadonlyArray, NonEmptyReadonlyArray } from "../Array.js";
|
|
103
|
+
import { assert } from "../Assert.js";
|
|
104
|
+
import {
|
|
105
|
+
Buffer,
|
|
106
|
+
BufferError,
|
|
107
|
+
bytesToHex,
|
|
108
|
+
bytesToUtf8,
|
|
109
|
+
concatBytes,
|
|
110
|
+
createBuffer,
|
|
111
|
+
hexToBytes,
|
|
112
|
+
utf8ToBytes,
|
|
113
|
+
} from "../Buffer.js";
|
|
114
|
+
import {
|
|
115
|
+
CreateRandomBytesDep,
|
|
116
|
+
EncryptionKey,
|
|
117
|
+
padmePaddingLength,
|
|
118
|
+
SymmetricCryptoDecryptError,
|
|
119
|
+
SymmetricCryptoDep,
|
|
120
|
+
} from "../Crypto.js";
|
|
121
|
+
import { eqArrayNumber } from "../Eq.js";
|
|
122
|
+
import { computeBalancedBuckets } from "../Number.js";
|
|
123
|
+
import { objectToEntries, ReadonlyRecord } from "../Object.js";
|
|
124
|
+
import { err, ok, Result } from "../Result.js";
|
|
125
|
+
import { SqliteValue } from "../Sqlite.js";
|
|
126
|
+
import {
|
|
127
|
+
Base64Url,
|
|
128
|
+
DateIsoString,
|
|
129
|
+
Id,
|
|
130
|
+
idTypeValueLength,
|
|
131
|
+
JsonValueFromString,
|
|
132
|
+
maxLength,
|
|
133
|
+
NanoId,
|
|
134
|
+
NonNegativeInt,
|
|
135
|
+
Number,
|
|
136
|
+
PositiveInt,
|
|
137
|
+
} from "../Type.js";
|
|
138
|
+
import { Brand, Predicate } from "../Types.js";
|
|
139
|
+
import {
|
|
140
|
+
OwnerId,
|
|
141
|
+
OwnerWithWriteAccess,
|
|
142
|
+
WriteKey,
|
|
143
|
+
writeKeyLength,
|
|
144
|
+
} from "./Owner.js";
|
|
145
|
+
import {
|
|
146
|
+
BinaryTimestamp,
|
|
147
|
+
binaryTimestampToTimestamp,
|
|
148
|
+
Counter,
|
|
149
|
+
Millis,
|
|
150
|
+
NodeId,
|
|
151
|
+
Timestamp,
|
|
152
|
+
timestampToBinaryTimestamp,
|
|
153
|
+
} from "./Timestamp.js";
|
|
154
|
+
|
|
155
|
+
/** Maximum size of the entire protocol message in bytes. */
|
|
156
|
+
export const maxProtocolMessageSize = 1_000_000 as PositiveInt;
|
|
157
|
+
|
|
158
|
+
/** Maximum size of the ranges in bytes. */
|
|
159
|
+
export const maxProtocolMessageRangesSize = 30_000 as PositiveInt;
|
|
160
|
+
|
|
161
|
+
/** Evolu Protocol Message. */
|
|
162
|
+
export type ProtocolMessage = Uint8Array & Brand<"ProtocolMessage">;
|
|
163
|
+
|
|
164
|
+
/** Evolu Protocol version. */
|
|
165
|
+
export const protocolVersion = 0 as NonNegativeInt;
|
|
166
|
+
|
|
167
|
+
export const ProtocolErrorCode = {
|
|
168
|
+
NoError: 0,
|
|
169
|
+
/** A code for {@link ProtocolWriteKeyError}. */
|
|
170
|
+
WriteKeyError: 1,
|
|
171
|
+
/** A code for {@link ProtocolWriteError}. */
|
|
172
|
+
WriteError: 2,
|
|
173
|
+
/** A code for {@link ProtocolSyncError}. */
|
|
174
|
+
SyncError: 3,
|
|
175
|
+
} as const;
|
|
176
|
+
|
|
177
|
+
type ProtocolErrorCode =
|
|
178
|
+
(typeof ProtocolErrorCode)[keyof typeof ProtocolErrorCode];
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Evolu Protocol Storage
|
|
182
|
+
*
|
|
183
|
+
* The protocol is agnostic to storage implementation details—any storage can be
|
|
184
|
+
* plugged in, as long as it implements this interface. Implementations must
|
|
185
|
+
* handle their own errors; return values only indicates overall success or
|
|
186
|
+
* failure.
|
|
187
|
+
*/
|
|
188
|
+
export interface Storage {
|
|
189
|
+
readonly getSize: (ownerId: BinaryOwnerId) => NonNegativeInt | null;
|
|
190
|
+
|
|
191
|
+
readonly fingerprint: (
|
|
192
|
+
ownerId: BinaryOwnerId,
|
|
193
|
+
begin: NonNegativeInt,
|
|
194
|
+
end: NonNegativeInt,
|
|
195
|
+
) => Fingerprint | null;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Computes fingerprints with their upper bounds in one call.
|
|
199
|
+
*
|
|
200
|
+
* This function can be replaced with many fingerprint/findLowerBound calls,
|
|
201
|
+
* but implementations can leverage it for batching and more efficient
|
|
202
|
+
* fingerprint computation.
|
|
203
|
+
*/
|
|
204
|
+
readonly fingerprintRanges: (
|
|
205
|
+
ownerId: BinaryOwnerId,
|
|
206
|
+
buckets: ReadonlyArray<NonNegativeInt>,
|
|
207
|
+
upperBound?: RangeUpperBound,
|
|
208
|
+
) => ReadonlyArray<FingerprintRange> | null;
|
|
209
|
+
|
|
210
|
+
readonly findLowerBound: (
|
|
211
|
+
ownerId: BinaryOwnerId,
|
|
212
|
+
begin: NonNegativeInt,
|
|
213
|
+
end: NonNegativeInt,
|
|
214
|
+
upperBound: RangeUpperBound,
|
|
215
|
+
) => NonNegativeInt | null;
|
|
216
|
+
|
|
217
|
+
readonly iterate: (
|
|
218
|
+
ownerId: BinaryOwnerId,
|
|
219
|
+
begin: NonNegativeInt,
|
|
220
|
+
end: NonNegativeInt,
|
|
221
|
+
callback: (timestamp: BinaryTimestamp, index: NonNegativeInt) => boolean,
|
|
222
|
+
) => void;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Authorizes the initiator's {@link WriteKey} for the given
|
|
226
|
+
* {@link BinaryOwnerId}.
|
|
227
|
+
*
|
|
228
|
+
* For a client that does not expect foreign writes, return `false`.
|
|
229
|
+
*/
|
|
230
|
+
readonly validateWriteKey: (
|
|
231
|
+
ownerId: BinaryOwnerId,
|
|
232
|
+
writeKey: WriteKey,
|
|
233
|
+
) => boolean;
|
|
234
|
+
|
|
235
|
+
/** Write encrypted {@link CrdtMessage}s to storage. */
|
|
236
|
+
readonly writeMessages: (
|
|
237
|
+
ownerId: BinaryOwnerId,
|
|
238
|
+
messages: NonEmptyReadonlyArray<EncryptedCrdtMessage>,
|
|
239
|
+
) => boolean;
|
|
240
|
+
|
|
241
|
+
/** Read encrypted {@link DbChange}s from storage. */
|
|
242
|
+
readonly readDbChange: (
|
|
243
|
+
ownerId: BinaryOwnerId,
|
|
244
|
+
timestamp: BinaryTimestamp,
|
|
245
|
+
) => EncryptedDbChange | null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface StorageDep {
|
|
249
|
+
readonly storage: Storage;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** An encrypted {@link CrdtMessage}. */
|
|
253
|
+
export interface EncryptedCrdtMessage {
|
|
254
|
+
readonly timestamp: Timestamp;
|
|
255
|
+
readonly change: EncryptedDbChange;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Encrypted DbChange */
|
|
259
|
+
export type EncryptedDbChange = Uint8Array & Brand<"EncryptedDbChange">;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A CRDT message that combines a unique {@link Timestamp} with a
|
|
263
|
+
* {@link DbChange}.
|
|
264
|
+
*/
|
|
265
|
+
export interface CrdtMessage {
|
|
266
|
+
readonly timestamp: Timestamp;
|
|
267
|
+
readonly change: DbChange;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* A DbChange is a change to a table row. Together with a unique
|
|
272
|
+
* {@link Timestamp}, it forms a {@link CrdtMessage}.
|
|
273
|
+
*/
|
|
274
|
+
export interface DbChange {
|
|
275
|
+
readonly table: Base64Url256;
|
|
276
|
+
readonly id: Id;
|
|
277
|
+
readonly values: ReadonlyRecord<Base64Url256, SqliteValue>;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const RangeType = {
|
|
281
|
+
Fingerprint: 1,
|
|
282
|
+
Skip: 0,
|
|
283
|
+
Timestamps: 2,
|
|
284
|
+
} as const;
|
|
285
|
+
|
|
286
|
+
export type RangeType = (typeof RangeType)[keyof typeof RangeType];
|
|
287
|
+
|
|
288
|
+
export const InfiniteUpperBound = Symbol("InfiniteUpperBound");
|
|
289
|
+
export type InfiniteUpperBound = typeof InfiniteUpperBound;
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Union type for Range's upperBound: either a {@link BinaryTimestamp} or
|
|
293
|
+
* {@link InfiniteUpperBound}.
|
|
294
|
+
*/
|
|
295
|
+
export type RangeUpperBound = BinaryTimestamp | InfiniteUpperBound;
|
|
296
|
+
|
|
297
|
+
interface BaseRange {
|
|
298
|
+
readonly upperBound: RangeUpperBound;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface SkipRange extends BaseRange {
|
|
302
|
+
readonly type: typeof RangeType.Skip;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface FingerprintRange extends BaseRange {
|
|
306
|
+
readonly type: typeof RangeType.Fingerprint;
|
|
307
|
+
readonly fingerprint: Fingerprint;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A cryptographic hash used for efficiently comparing collections of
|
|
312
|
+
* {@link BinaryTimestamp}s.
|
|
313
|
+
*
|
|
314
|
+
* It consists of the first {@link fingerprintSize} bytes of the SHA-256 hash of
|
|
315
|
+
* one or more timestamps.
|
|
316
|
+
*/
|
|
317
|
+
export type Fingerprint = Uint8Array & Brand<"Fingerprint">;
|
|
318
|
+
|
|
319
|
+
export const fingerprintSize = 12 as NonNegativeInt;
|
|
320
|
+
|
|
321
|
+
/** A fingerprint of an empty range. */
|
|
322
|
+
export const zeroFingerprint = new Uint8Array(fingerprintSize) as Fingerprint;
|
|
323
|
+
|
|
324
|
+
export interface TimestampsRange extends BaseRange {
|
|
325
|
+
readonly type: typeof RangeType.Timestamps;
|
|
326
|
+
readonly timestamps: ReadonlyArray<BinaryTimestamp>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export interface TimestampsRangeWithTimestampsBuffer extends BaseRange {
|
|
330
|
+
readonly type: typeof RangeType.Timestamps;
|
|
331
|
+
readonly timestamps: TimestampsBuffer;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export type Range = SkipRange | FingerprintRange | TimestampsRange;
|
|
335
|
+
|
|
336
|
+
export type ProtocolError =
|
|
337
|
+
| ProtocolUnsupportedVersionError
|
|
338
|
+
| ProtocolInvalidDataError
|
|
339
|
+
| ProtocolWriteKeyError
|
|
340
|
+
| ProtocolWriteError
|
|
341
|
+
| ProtocolSyncError;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Represents a version mismatch in the Evolu Protocol. Occurs when the
|
|
345
|
+
* initiator and non-initiator are using incompatible protocol versions.
|
|
346
|
+
*/
|
|
347
|
+
export interface ProtocolUnsupportedVersionError {
|
|
348
|
+
readonly type: "ProtocolUnsupportedVersionError";
|
|
349
|
+
readonly unsupportedVersion: NonNegativeInt;
|
|
350
|
+
/** Indicates which side is obsolete and should update. */
|
|
351
|
+
readonly isInitiator: boolean;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Error for invalid or corrupted protocol message data. */
|
|
355
|
+
export interface ProtocolInvalidDataError {
|
|
356
|
+
readonly type: "ProtocolInvalidDataError";
|
|
357
|
+
readonly data: globalThis.Uint8Array;
|
|
358
|
+
readonly error: unknown;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Error when a {@link WriteKey} is invalid, missing, or fails validation. */
|
|
362
|
+
export interface ProtocolWriteKeyError {
|
|
363
|
+
readonly type: "ProtocolWriteKeyError";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Error when a write fails due to storage limits or billing requirements.
|
|
368
|
+
* Indicates the need to expand capacity or resolve payment issues.
|
|
369
|
+
*/
|
|
370
|
+
export interface ProtocolWriteError {
|
|
371
|
+
readonly type: "ProtocolWriteError";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Error indicating a synchronization failure during the protocol exchange. Used
|
|
376
|
+
* for unexpected or generic sync errors not covered by other error types.
|
|
377
|
+
*/
|
|
378
|
+
export interface ProtocolSyncError {
|
|
379
|
+
readonly type: "ProtocolSyncError";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Creates a {@link ProtocolMessage} from CRDT messages.
|
|
384
|
+
*
|
|
385
|
+
* If the message size would exceed {@link maxProtocolMessageSize}, the protocol
|
|
386
|
+
* ensures all messages will be sent in the next round(s) even over
|
|
387
|
+
* unidirectional and stateless transports.
|
|
388
|
+
*/
|
|
389
|
+
export const createProtocolMessageFromCrdtMessages =
|
|
390
|
+
(deps: SymmetricCryptoDep & CreateRandomBytesDep) =>
|
|
391
|
+
(
|
|
392
|
+
owner: OwnerWithWriteAccess,
|
|
393
|
+
messages: NonEmptyReadonlyArray<CrdtMessage>,
|
|
394
|
+
maxSize?: PositiveInt,
|
|
395
|
+
): ProtocolMessage => {
|
|
396
|
+
const buffer = createProtocolMessageBuffer(owner.id, {
|
|
397
|
+
totalMaxSize: maxSize ?? maxProtocolMessageSize,
|
|
398
|
+
writeKey: owner.writeKey,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
let notAllMessagesSent = false;
|
|
402
|
+
|
|
403
|
+
for (const message of messages) {
|
|
404
|
+
const change = encodeAndEncryptDbChange(deps)(
|
|
405
|
+
message.change,
|
|
406
|
+
owner.encryptionKey,
|
|
407
|
+
);
|
|
408
|
+
const encryptedCrdtMessage = { timestamp: message.timestamp, change };
|
|
409
|
+
if (buffer.canAddMessage(encryptedCrdtMessage)) {
|
|
410
|
+
buffer.addMessage(encryptedCrdtMessage);
|
|
411
|
+
} else {
|
|
412
|
+
notAllMessagesSent = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (notAllMessagesSent) {
|
|
418
|
+
/**
|
|
419
|
+
* DEV: If not all messages fit due to size limits, we trigger a sync
|
|
420
|
+
* continuation by appending a Range with a random fingerprint. This
|
|
421
|
+
* ensures the receiver always responds with ranges, prompting another
|
|
422
|
+
* sync round.
|
|
423
|
+
*
|
|
424
|
+
* The ideal approach would be to send three ranges (skip, fingerprint,
|
|
425
|
+
* skip) where the fingerprint of unsent messages would act as narrow sync
|
|
426
|
+
* probe. I think we can send {@link zeroFingerprint} which can be
|
|
427
|
+
* interpreted as an indication that the other side should reply with
|
|
428
|
+
* {@link TimestampsRange}, so no need to restart syncing.
|
|
429
|
+
*
|
|
430
|
+
* For now, using a random fingerprint avoids extra complexity and is good
|
|
431
|
+
* enough for this case.
|
|
432
|
+
*/
|
|
433
|
+
const randomFingerprint = deps.createRandomBytes(
|
|
434
|
+
fingerprintSize,
|
|
435
|
+
) as unknown as Fingerprint;
|
|
436
|
+
|
|
437
|
+
// There is always a space for Fingerprint with InfiniteUpperBound.
|
|
438
|
+
buffer.addRange({
|
|
439
|
+
type: RangeType.Fingerprint,
|
|
440
|
+
upperBound: InfiniteUpperBound,
|
|
441
|
+
fingerprint: randomFingerprint,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return buffer.unwrap();
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/** Creates a {@link ProtocolMessage} for sync. */
|
|
449
|
+
export const createProtocolMessageForSync =
|
|
450
|
+
(deps: StorageDep) =>
|
|
451
|
+
(ownerId: OwnerId): ProtocolMessage | null => {
|
|
452
|
+
const buffer = createProtocolMessageBuffer(ownerId);
|
|
453
|
+
const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
|
|
454
|
+
|
|
455
|
+
const size = deps.storage.getSize(binaryOwnerId);
|
|
456
|
+
// Errors are handled by the storage.
|
|
457
|
+
if (size == null) return null;
|
|
458
|
+
|
|
459
|
+
splitRange(deps)(
|
|
460
|
+
binaryOwnerId,
|
|
461
|
+
0 as NonNegativeInt,
|
|
462
|
+
size,
|
|
463
|
+
InfiniteUpperBound,
|
|
464
|
+
buffer,
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
return buffer.unwrap();
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Mutable builder for constructing {@link ProtocolMessage} respecting size
|
|
472
|
+
* limits.
|
|
473
|
+
*/
|
|
474
|
+
export interface ProtocolMessageBuffer {
|
|
475
|
+
readonly canAddMessage: (message: EncryptedCrdtMessage) => boolean;
|
|
476
|
+
|
|
477
|
+
readonly addMessage: (message: EncryptedCrdtMessage) => void;
|
|
478
|
+
|
|
479
|
+
readonly canSplitRange: () => boolean;
|
|
480
|
+
|
|
481
|
+
readonly canAddTimestampsRangeAndMessage: (
|
|
482
|
+
timestamps: TimestampsBuffer,
|
|
483
|
+
message: EncryptedCrdtMessage | null,
|
|
484
|
+
) => boolean;
|
|
485
|
+
|
|
486
|
+
readonly addRange: (
|
|
487
|
+
range: SkipRange | FingerprintRange | TimestampsRangeWithTimestampsBuffer,
|
|
488
|
+
) => void;
|
|
489
|
+
|
|
490
|
+
readonly unwrap: () => ProtocolMessage;
|
|
491
|
+
readonly getSize: () => PositiveInt;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export const createProtocolMessageBuffer = (
|
|
495
|
+
ownerId: OwnerId,
|
|
496
|
+
options: {
|
|
497
|
+
readonly errorCode?: ProtocolErrorCode;
|
|
498
|
+
readonly writeKey?: WriteKey;
|
|
499
|
+
readonly totalMaxSize?: PositiveInt | undefined;
|
|
500
|
+
readonly rangesMaxSize?: PositiveInt | undefined;
|
|
501
|
+
readonly version?: NonNegativeInt;
|
|
502
|
+
} = {},
|
|
503
|
+
): ProtocolMessageBuffer => {
|
|
504
|
+
const {
|
|
505
|
+
errorCode,
|
|
506
|
+
writeKey,
|
|
507
|
+
totalMaxSize = maxProtocolMessageSize,
|
|
508
|
+
rangesMaxSize = maxProtocolMessageRangesSize,
|
|
509
|
+
version = protocolVersion,
|
|
510
|
+
} = options;
|
|
511
|
+
|
|
512
|
+
const buffers = {
|
|
513
|
+
header: createBuffer(),
|
|
514
|
+
messages: {
|
|
515
|
+
timestamps: createTimestampsBuffer(),
|
|
516
|
+
dbChanges: createBuffer(),
|
|
517
|
+
},
|
|
518
|
+
ranges: {
|
|
519
|
+
timestamps: createTimestampsBuffer(),
|
|
520
|
+
types: createBuffer(),
|
|
521
|
+
payloads: createBuffer(),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
encodeNonNegativeInt(buffers.header, version);
|
|
526
|
+
buffers.header.extend(ownerIdToBinaryOwnerId(ownerId));
|
|
527
|
+
if (errorCode != null) buffers.header.extend([errorCode]);
|
|
528
|
+
|
|
529
|
+
let isLastRangeInfinite = false;
|
|
530
|
+
|
|
531
|
+
const isWithinSizeLimits = () => getSize() <= totalMaxSize;
|
|
532
|
+
|
|
533
|
+
const getSize = () =>
|
|
534
|
+
(getHeaderAndMessagesSize() + getRangesSize()) as PositiveInt;
|
|
535
|
+
|
|
536
|
+
const getHeaderAndMessagesSize = () =>
|
|
537
|
+
buffers.header.getLength() +
|
|
538
|
+
buffers.messages.timestamps.getLength() +
|
|
539
|
+
buffers.messages.dbChanges.getLength() +
|
|
540
|
+
(buffers.messages.timestamps.getCount() > 0 && writeKey
|
|
541
|
+
? writeKeyLength
|
|
542
|
+
: 0);
|
|
543
|
+
|
|
544
|
+
const getRangesSize = () =>
|
|
545
|
+
buffers.ranges.timestamps.getCount() > 0
|
|
546
|
+
? buffers.ranges.timestamps.getLength() +
|
|
547
|
+
buffers.ranges.types.getLength() +
|
|
548
|
+
buffers.ranges.payloads.getLength() +
|
|
549
|
+
safeMargins.remainingRange
|
|
550
|
+
: 0;
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* We calculated worst-case sizes as closely as possible and added a small
|
|
554
|
+
* safety margin, since computing exact worst cases is difficult due to
|
|
555
|
+
* variable-length, run-length, and delta encoding.
|
|
556
|
+
*
|
|
557
|
+
* Runtime assertions (`assert`) are used to guarantee that size limits are
|
|
558
|
+
* never exceeded. If a limit is exceeded, the assertion will fail at the
|
|
559
|
+
* precise location, making it easy to identify and fix the issue.
|
|
560
|
+
*
|
|
561
|
+
* While it would be possible to avoid the safety margin by snapshotting
|
|
562
|
+
* buffer states and rolling back changes, this would likely impact
|
|
563
|
+
* performance. If someone has time and wants to experiment with this
|
|
564
|
+
* approach, contributions are welcome.
|
|
565
|
+
*/
|
|
566
|
+
const safeMargins = {
|
|
567
|
+
remainingRange: fingerprintSize + 10, // bytes: range type + possible increased count varint
|
|
568
|
+
timestamp: 30, // bytes: max millis + max count + NodeId
|
|
569
|
+
dbChangeLength: 8, // bytes: maximum encoded DbChange length varint
|
|
570
|
+
splitRange: 800, // bytes: worst case is around 650 bytes
|
|
571
|
+
timestampsRange: 50, // bytes: range type + its upperBound + possible increased count varint
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const addMessageSafeMargin =
|
|
575
|
+
safeMargins.timestamp +
|
|
576
|
+
safeMargins.dbChangeLength +
|
|
577
|
+
safeMargins.remainingRange;
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
canAddMessage: (message) =>
|
|
581
|
+
getSize() + addMessageSafeMargin + message.change.length <= totalMaxSize,
|
|
582
|
+
|
|
583
|
+
addMessage: (message) => {
|
|
584
|
+
buffers.messages.timestamps.add(message.timestamp);
|
|
585
|
+
encodeLength(buffers.messages.dbChanges, message.change);
|
|
586
|
+
buffers.messages.dbChanges.extend(message.change);
|
|
587
|
+
assert(isWithinSizeLimits(), "the message is too big");
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
canSplitRange: () => {
|
|
591
|
+
return getRangesSize() + safeMargins.splitRange <= rangesMaxSize;
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
canAddTimestampsRangeAndMessage: (timestamps, message) => {
|
|
595
|
+
const rangesNewSize =
|
|
596
|
+
getRangesSize() + timestamps.getLength() + safeMargins.timestampsRange;
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
rangesNewSize <= rangesMaxSize &&
|
|
600
|
+
(message
|
|
601
|
+
? getHeaderAndMessagesSize() +
|
|
602
|
+
rangesNewSize +
|
|
603
|
+
addMessageSafeMargin +
|
|
604
|
+
message.change.length <=
|
|
605
|
+
totalMaxSize
|
|
606
|
+
: true)
|
|
607
|
+
);
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
addRange: (range) => {
|
|
611
|
+
assert(
|
|
612
|
+
!isLastRangeInfinite,
|
|
613
|
+
"Cannot add a range after an InfiniteUpperBound range",
|
|
614
|
+
);
|
|
615
|
+
isLastRangeInfinite = range.upperBound === InfiniteUpperBound;
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* We don't have to encode InfiniteUpperBound timestamp since it's always
|
|
619
|
+
* the last because ranges cover the whole universe. For partial sync, we
|
|
620
|
+
* use SkipRange.
|
|
621
|
+
*/
|
|
622
|
+
if (range.upperBound !== InfiniteUpperBound)
|
|
623
|
+
buffers.ranges.timestamps.add(
|
|
624
|
+
binaryTimestampToTimestamp(range.upperBound),
|
|
625
|
+
);
|
|
626
|
+
else {
|
|
627
|
+
buffers.ranges.timestamps.addInfinite();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
encodeNonNegativeInt(buffers.ranges.types, range.type as NonNegativeInt);
|
|
631
|
+
|
|
632
|
+
switch (range.type) {
|
|
633
|
+
case RangeType.Skip:
|
|
634
|
+
break;
|
|
635
|
+
case RangeType.Fingerprint:
|
|
636
|
+
buffers.ranges.payloads.extend(range.fingerprint);
|
|
637
|
+
break;
|
|
638
|
+
case RangeType.Timestamps: {
|
|
639
|
+
range.timestamps.append(buffers.ranges.payloads);
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
assert(isWithinSizeLimits(), `the range ${range.type} is too big`);
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
unwrap: () => {
|
|
648
|
+
if (buffers.ranges.timestamps.getCount() > 0) {
|
|
649
|
+
assert(
|
|
650
|
+
isLastRangeInfinite,
|
|
651
|
+
"The last range's upperBound must be InfiniteUpperBound",
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
buffers.messages.timestamps.append(buffers.header);
|
|
656
|
+
buffers.header.extend(buffers.messages.dbChanges.unwrap());
|
|
657
|
+
if (buffers.messages.timestamps.getCount() > 0 && writeKey)
|
|
658
|
+
buffers.header.extend(writeKey);
|
|
659
|
+
|
|
660
|
+
if (buffers.ranges.timestamps.getCount() > 0) {
|
|
661
|
+
buffers.ranges.timestamps.append(buffers.header);
|
|
662
|
+
buffers.header.extend(buffers.ranges.types.unwrap());
|
|
663
|
+
buffers.header.extend(buffers.ranges.payloads.unwrap());
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return buffers.header.unwrap() as ProtocolMessage;
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
getSize,
|
|
670
|
+
};
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
export interface TimestampsBuffer {
|
|
674
|
+
readonly add: (timestamp: Timestamp) => void;
|
|
675
|
+
readonly addInfinite: () => void;
|
|
676
|
+
readonly getCount: () => NonNegativeInt;
|
|
677
|
+
readonly getLength: () => number;
|
|
678
|
+
readonly append: (buffer: Buffer) => void;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export const createTimestampsBuffer = (): TimestampsBuffer => {
|
|
682
|
+
let count = 0 as NonNegativeInt;
|
|
683
|
+
const countBuffer = createBuffer();
|
|
684
|
+
|
|
685
|
+
const syncCount = () => {
|
|
686
|
+
countBuffer.reset();
|
|
687
|
+
encodeNonNegativeInt(countBuffer, count);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
syncCount();
|
|
691
|
+
|
|
692
|
+
const millisBuffer = createBuffer();
|
|
693
|
+
let previousMillis = 0 as Millis;
|
|
694
|
+
|
|
695
|
+
const counterEncoder = createRunLengthEncoder<Counter>((buffer, value) => {
|
|
696
|
+
encodeNonNegativeInt(buffer, value);
|
|
697
|
+
});
|
|
698
|
+
const nodeIdEncoder = createRunLengthEncoder<NodeId>((buffer, value) => {
|
|
699
|
+
encodeNodeId(buffer, value);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
add: (timestamp) => {
|
|
704
|
+
const delta = timestamp.millis - previousMillis;
|
|
705
|
+
assert(NonNegativeInt.is(delta), "The delta must be NonNegativeInt");
|
|
706
|
+
|
|
707
|
+
count++;
|
|
708
|
+
syncCount();
|
|
709
|
+
|
|
710
|
+
previousMillis = timestamp.millis;
|
|
711
|
+
encodeNonNegativeInt(millisBuffer, delta);
|
|
712
|
+
|
|
713
|
+
counterEncoder.add(timestamp.counter);
|
|
714
|
+
nodeIdEncoder.add(timestamp.nodeId);
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
addInfinite: () => {
|
|
718
|
+
count++;
|
|
719
|
+
syncCount();
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
getCount: () => count,
|
|
723
|
+
|
|
724
|
+
getLength: () =>
|
|
725
|
+
countBuffer.getLength() +
|
|
726
|
+
millisBuffer.getLength() +
|
|
727
|
+
counterEncoder.getLength() +
|
|
728
|
+
nodeIdEncoder.getLength(),
|
|
729
|
+
|
|
730
|
+
append: (buffer) => {
|
|
731
|
+
buffer.extend(countBuffer.unwrap());
|
|
732
|
+
buffer.extend(millisBuffer.unwrap());
|
|
733
|
+
buffer.extend(counterEncoder.unwrap());
|
|
734
|
+
buffer.extend(nodeIdEncoder.unwrap());
|
|
735
|
+
},
|
|
736
|
+
};
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
interface RunLengthEncoder<T> {
|
|
740
|
+
add: (value: T) => void;
|
|
741
|
+
getLength: () => NonNegativeInt;
|
|
742
|
+
unwrap: () => Uint8Array;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const createRunLengthEncoder = <T>(
|
|
746
|
+
encodeValue: (buffer: Buffer, value: T) => void,
|
|
747
|
+
): RunLengthEncoder<T> => {
|
|
748
|
+
const buffer = createBuffer();
|
|
749
|
+
let previousLength = 0 as NonNegativeInt;
|
|
750
|
+
let previousValue = null as T | null;
|
|
751
|
+
let runLength = 0 as NonNegativeInt;
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
add: (value) => {
|
|
755
|
+
if (value === previousValue) {
|
|
756
|
+
runLength++;
|
|
757
|
+
buffer.truncate(previousLength);
|
|
758
|
+
} else {
|
|
759
|
+
previousValue = value;
|
|
760
|
+
runLength = 1 as NonNegativeInt;
|
|
761
|
+
}
|
|
762
|
+
previousLength = buffer.getLength();
|
|
763
|
+
encodeValue(buffer, value);
|
|
764
|
+
encodeNonNegativeInt(buffer, runLength);
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
getLength: () => buffer.getLength(),
|
|
768
|
+
|
|
769
|
+
unwrap: () => buffer.unwrap(),
|
|
770
|
+
};
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
export interface ApplyProtocolMessageAsClientOptions {
|
|
774
|
+
getWriteKey?: (ownerId: OwnerId) => WriteKey | null;
|
|
775
|
+
|
|
776
|
+
/** For testing purposes only; should not be used in production. */
|
|
777
|
+
version?: NonNegativeInt;
|
|
778
|
+
|
|
779
|
+
totalMaxSize?: PositiveInt;
|
|
780
|
+
rangesMaxSize?: PositiveInt;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export const applyProtocolMessageAsClient =
|
|
784
|
+
(deps: StorageDep) =>
|
|
785
|
+
(
|
|
786
|
+
inputMessage: Uint8Array,
|
|
787
|
+
{
|
|
788
|
+
getWriteKey,
|
|
789
|
+
version = protocolVersion,
|
|
790
|
+
totalMaxSize,
|
|
791
|
+
rangesMaxSize,
|
|
792
|
+
}: ApplyProtocolMessageAsClientOptions = {},
|
|
793
|
+
): Result<ProtocolMessage | null, ProtocolError> =>
|
|
794
|
+
tryDecodeProtocolData<ProtocolMessage | null, ProtocolError>(
|
|
795
|
+
inputMessage,
|
|
796
|
+
(input) => {
|
|
797
|
+
const requestedVersion = decodeNonNegativeInt(input);
|
|
798
|
+
|
|
799
|
+
if (requestedVersion !== version) {
|
|
800
|
+
return err<ProtocolUnsupportedVersionError>({
|
|
801
|
+
type: "ProtocolUnsupportedVersionError",
|
|
802
|
+
unsupportedVersion: requestedVersion,
|
|
803
|
+
isInitiator: version < requestedVersion,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const ownerId = decodeOwnerId(input);
|
|
808
|
+
const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
|
|
809
|
+
|
|
810
|
+
const errorCode = input.shift() as ProtocolErrorCode;
|
|
811
|
+
if (errorCode !== ProtocolErrorCode.NoError) {
|
|
812
|
+
switch (errorCode) {
|
|
813
|
+
case ProtocolErrorCode.WriteKeyError:
|
|
814
|
+
return err<ProtocolWriteKeyError>({
|
|
815
|
+
type: "ProtocolWriteKeyError",
|
|
816
|
+
});
|
|
817
|
+
case ProtocolErrorCode.WriteError:
|
|
818
|
+
return err<ProtocolWriteError>({
|
|
819
|
+
type: "ProtocolWriteError",
|
|
820
|
+
});
|
|
821
|
+
case ProtocolErrorCode.SyncError:
|
|
822
|
+
return err<ProtocolSyncError>({
|
|
823
|
+
type: "ProtocolSyncError",
|
|
824
|
+
});
|
|
825
|
+
default:
|
|
826
|
+
throw new ProtocolDecodeError(
|
|
827
|
+
`Invalid ProtocolErrorCode: ${errorCode}`,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const messages = decodeMessages(input);
|
|
833
|
+
|
|
834
|
+
if (
|
|
835
|
+
isNonEmptyReadonlyArray(messages) &&
|
|
836
|
+
!deps.storage.writeMessages(binaryOwnerId, messages)
|
|
837
|
+
) {
|
|
838
|
+
return ok(null);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (!getWriteKey) return ok(null);
|
|
842
|
+
const writeKey = getWriteKey(ownerId);
|
|
843
|
+
if (writeKey == null) return ok(null);
|
|
844
|
+
|
|
845
|
+
const output = createProtocolMessageBuffer(ownerId, {
|
|
846
|
+
writeKey,
|
|
847
|
+
totalMaxSize,
|
|
848
|
+
rangesMaxSize,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
return sync(deps)("initiator", input, output, binaryOwnerId);
|
|
852
|
+
},
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
export interface ApplyProtocolMessageAsRelayOptions {
|
|
856
|
+
/** To subscribe an owner for broadcasting. */
|
|
857
|
+
subscribe?: (ownerId: OwnerId) => void;
|
|
858
|
+
|
|
859
|
+
/** To broadcast a protocol message to all subscribers. */
|
|
860
|
+
broadcast?: (ownerId: OwnerId, message: ProtocolMessage) => void;
|
|
861
|
+
|
|
862
|
+
totalMaxSize?: PositiveInt;
|
|
863
|
+
rangesMaxSize?: PositiveInt;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export const applyProtocolMessageAsRelay =
|
|
867
|
+
(deps: StorageDep) =>
|
|
868
|
+
(
|
|
869
|
+
inputMessage: Uint8Array,
|
|
870
|
+
{
|
|
871
|
+
subscribe,
|
|
872
|
+
broadcast,
|
|
873
|
+
totalMaxSize,
|
|
874
|
+
rangesMaxSize,
|
|
875
|
+
}: ApplyProtocolMessageAsRelayOptions = {},
|
|
876
|
+
/** For testing purposes only; should not be used in production. */
|
|
877
|
+
version = protocolVersion,
|
|
878
|
+
): Result<ProtocolMessage | null, ProtocolInvalidDataError> =>
|
|
879
|
+
tryDecodeProtocolData(inputMessage, (input) => {
|
|
880
|
+
const requestedVersion = decodeNonNegativeInt(input);
|
|
881
|
+
|
|
882
|
+
if (requestedVersion !== version) {
|
|
883
|
+
// Non-initiator responds with its version.
|
|
884
|
+
const output = createBuffer();
|
|
885
|
+
encodeNonNegativeInt(output, version);
|
|
886
|
+
return ok(output.unwrap() as ProtocolMessage);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const ownerId = decodeOwnerId(input);
|
|
890
|
+
const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
|
|
891
|
+
|
|
892
|
+
subscribe?.(ownerId);
|
|
893
|
+
|
|
894
|
+
const messages = decodeMessages(input);
|
|
895
|
+
|
|
896
|
+
if (isNonEmptyReadonlyArray(messages)) {
|
|
897
|
+
const messagesEnd = inputMessage.length - input.getLength();
|
|
898
|
+
const writeKey = input.shiftN(writeKeyLength) as WriteKey;
|
|
899
|
+
|
|
900
|
+
const writeKeyIsValid = deps.storage.validateWriteKey(
|
|
901
|
+
binaryOwnerId,
|
|
902
|
+
writeKey,
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
if (!writeKeyIsValid)
|
|
906
|
+
return ok(
|
|
907
|
+
createProtocolMessageBuffer(ownerId, {
|
|
908
|
+
errorCode: ProtocolErrorCode.WriteKeyError,
|
|
909
|
+
}).unwrap(),
|
|
910
|
+
);
|
|
911
|
+
|
|
912
|
+
if (broadcast) {
|
|
913
|
+
// Instead of encoding a new protocol message, we reuse the inputMessage.
|
|
914
|
+
const broadcastMessage = concatBytes(
|
|
915
|
+
inputMessage.slice(0, 17),
|
|
916
|
+
new Uint8Array([ProtocolErrorCode.NoError]),
|
|
917
|
+
inputMessage.slice(17, messagesEnd),
|
|
918
|
+
) as ProtocolMessage;
|
|
919
|
+
|
|
920
|
+
broadcast(ownerId, broadcastMessage);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (!deps.storage.writeMessages(binaryOwnerId, messages))
|
|
924
|
+
return ok(
|
|
925
|
+
createProtocolMessageBuffer(ownerId, {
|
|
926
|
+
errorCode: ProtocolErrorCode.WriteError,
|
|
927
|
+
}).unwrap(),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const output = createProtocolMessageBuffer(ownerId, {
|
|
932
|
+
errorCode: ProtocolErrorCode.NoError,
|
|
933
|
+
totalMaxSize,
|
|
934
|
+
rangesMaxSize,
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
return sync(deps)("non-initiator", input, output, binaryOwnerId);
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Wraps Evolu Protocol decoding functions, which use exceptions instead of
|
|
942
|
+
* {@link Result} to provide stack traces for debugging and reduce allocation
|
|
943
|
+
* overhead in success cases.
|
|
944
|
+
*/
|
|
945
|
+
const tryDecodeProtocolData = <T, E>(
|
|
946
|
+
data: Uint8Array,
|
|
947
|
+
callback: (buffer: Buffer) => Result<T, E | ProtocolInvalidDataError>,
|
|
948
|
+
) => {
|
|
949
|
+
try {
|
|
950
|
+
return callback(createBuffer(data));
|
|
951
|
+
} catch (error: unknown) {
|
|
952
|
+
if (error instanceof ProtocolDecodeError || error instanceof BufferError)
|
|
953
|
+
return err<ProtocolInvalidDataError>({
|
|
954
|
+
type: "ProtocolInvalidDataError",
|
|
955
|
+
data,
|
|
956
|
+
error,
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Error thrown for internal protocol validation failures, such as invalid data
|
|
965
|
+
* or type errors.
|
|
966
|
+
*/
|
|
967
|
+
class ProtocolDecodeError extends Error {
|
|
968
|
+
constructor(message: string) {
|
|
969
|
+
super(message);
|
|
970
|
+
this.name = this.constructor.name;
|
|
971
|
+
|
|
972
|
+
Error.captureStackTrace(this, this.constructor);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const decodeMessages = (
|
|
977
|
+
buffer: Buffer,
|
|
978
|
+
): ReadonlyArray<EncryptedCrdtMessage> => {
|
|
979
|
+
const timestamps = decodeTimestamps(buffer);
|
|
980
|
+
|
|
981
|
+
const messages: Array<EncryptedCrdtMessage> = [];
|
|
982
|
+
for (const timestamp of timestamps) {
|
|
983
|
+
const changeLength = decodeLength(buffer);
|
|
984
|
+
const change = buffer.shiftN(changeLength) as EncryptedDbChange;
|
|
985
|
+
messages.push({ timestamp, change });
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return messages;
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
const sync =
|
|
992
|
+
(deps: StorageDep) =>
|
|
993
|
+
(
|
|
994
|
+
role: "initiator" | "non-initiator",
|
|
995
|
+
input: Buffer,
|
|
996
|
+
output: ProtocolMessageBuffer,
|
|
997
|
+
ownerId: BinaryOwnerId,
|
|
998
|
+
): Result<ProtocolMessage | null, never> => {
|
|
999
|
+
const ranges = decodeRanges(input);
|
|
1000
|
+
|
|
1001
|
+
if (!isNonEmptyReadonlyArray(ranges)) {
|
|
1002
|
+
// Nothing to sync.
|
|
1003
|
+
return ok(null);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const binaryOwnerId = binaryOwnerIdToOwnerId(ownerId);
|
|
1007
|
+
const outputInitialSize = output.getSize();
|
|
1008
|
+
|
|
1009
|
+
const syncFail = () => {
|
|
1010
|
+
// Only the relay (non-initiator) reports sync errors, not the client (initiator).
|
|
1011
|
+
if (role === "initiator") {
|
|
1012
|
+
return ok(null);
|
|
1013
|
+
}
|
|
1014
|
+
const message = createProtocolMessageBuffer(binaryOwnerId, {
|
|
1015
|
+
errorCode: ProtocolErrorCode.SyncError,
|
|
1016
|
+
});
|
|
1017
|
+
return ok(message.unwrap());
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
const storageSize = deps.storage.getSize(ownerId);
|
|
1021
|
+
if (storageSize == null) return syncFail();
|
|
1022
|
+
|
|
1023
|
+
let prevUpperBound: RangeUpperBound | null = null;
|
|
1024
|
+
let prevIndex = 0 as NonNegativeInt;
|
|
1025
|
+
|
|
1026
|
+
let skip = false;
|
|
1027
|
+
let nonSkipRangeAdded = false;
|
|
1028
|
+
|
|
1029
|
+
const skipRange = (
|
|
1030
|
+
range: SkipRange | FingerprintRange | TimestampsRange,
|
|
1031
|
+
) => {
|
|
1032
|
+
// The last range, if any non skip was added, must have InfiniteUpperBound.
|
|
1033
|
+
if (nonSkipRangeAdded && range.upperBound === InfiniteUpperBound) {
|
|
1034
|
+
output.addRange({
|
|
1035
|
+
type: RangeType.Skip,
|
|
1036
|
+
upperBound: InfiniteUpperBound,
|
|
1037
|
+
});
|
|
1038
|
+
} else {
|
|
1039
|
+
skip = true;
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
const coalesceSkipsBeforeAdd = () => {
|
|
1044
|
+
// Set to true because we are going to add a non skip range.
|
|
1045
|
+
nonSkipRangeAdded = true;
|
|
1046
|
+
if (skip) {
|
|
1047
|
+
skip = false;
|
|
1048
|
+
assert(prevUpperBound != null, "prevUpperBound is null");
|
|
1049
|
+
// There is always a space for a skip range before adding.
|
|
1050
|
+
output.addRange({
|
|
1051
|
+
type: RangeType.Skip,
|
|
1052
|
+
upperBound: prevUpperBound,
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
// When we don't have a space...
|
|
1058
|
+
const addFingerprintForRemainingRange = (
|
|
1059
|
+
begin: NonNegativeInt,
|
|
1060
|
+
): boolean => {
|
|
1061
|
+
const fingerprint = deps.storage.fingerprint(ownerId, begin, storageSize);
|
|
1062
|
+
if (!fingerprint) return false;
|
|
1063
|
+
// There is always a space for a ramaining range.
|
|
1064
|
+
output.addRange({
|
|
1065
|
+
type: RangeType.Fingerprint,
|
|
1066
|
+
upperBound: InfiniteUpperBound,
|
|
1067
|
+
fingerprint,
|
|
1068
|
+
});
|
|
1069
|
+
return true;
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
for (const range of ranges) {
|
|
1073
|
+
const currentUpperBound = range.upperBound;
|
|
1074
|
+
|
|
1075
|
+
const lower = prevIndex;
|
|
1076
|
+
let upper = deps.storage.findLowerBound(
|
|
1077
|
+
ownerId,
|
|
1078
|
+
prevIndex,
|
|
1079
|
+
storageSize,
|
|
1080
|
+
currentUpperBound,
|
|
1081
|
+
);
|
|
1082
|
+
if (upper == null) return syncFail();
|
|
1083
|
+
|
|
1084
|
+
switch (range.type) {
|
|
1085
|
+
case RangeType.Skip: {
|
|
1086
|
+
skipRange(range);
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
case RangeType.Fingerprint: {
|
|
1091
|
+
const ourFingerprint = deps.storage.fingerprint(
|
|
1092
|
+
ownerId,
|
|
1093
|
+
lower,
|
|
1094
|
+
upper,
|
|
1095
|
+
);
|
|
1096
|
+
if (ourFingerprint == null) return syncFail();
|
|
1097
|
+
|
|
1098
|
+
if (eqArrayNumber(range.fingerprint, ourFingerprint)) {
|
|
1099
|
+
skipRange(range);
|
|
1100
|
+
} else {
|
|
1101
|
+
if (output.canSplitRange()) {
|
|
1102
|
+
coalesceSkipsBeforeAdd();
|
|
1103
|
+
splitRange(deps)(
|
|
1104
|
+
ownerId,
|
|
1105
|
+
lower,
|
|
1106
|
+
upper,
|
|
1107
|
+
currentUpperBound,
|
|
1108
|
+
output,
|
|
1109
|
+
);
|
|
1110
|
+
} else {
|
|
1111
|
+
if (!addFingerprintForRemainingRange(upper)) return syncFail();
|
|
1112
|
+
return ok(output.unwrap());
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
case RangeType.Timestamps: {
|
|
1119
|
+
let endBound = currentUpperBound;
|
|
1120
|
+
|
|
1121
|
+
const timestampsWeNeed = new Map(
|
|
1122
|
+
range.timestamps.map((t) => [t.join(), true]),
|
|
1123
|
+
);
|
|
1124
|
+
const ourTimestamps = createTimestampsBuffer();
|
|
1125
|
+
|
|
1126
|
+
let storageError = false as boolean;
|
|
1127
|
+
let exceeded = false as boolean;
|
|
1128
|
+
|
|
1129
|
+
deps.storage.iterate(ownerId, lower, upper, (timestamp, index) => {
|
|
1130
|
+
const timestampString = timestamp.join();
|
|
1131
|
+
const timestampBinary = binaryTimestampToTimestamp(timestamp);
|
|
1132
|
+
|
|
1133
|
+
let message: EncryptedCrdtMessage | null = null;
|
|
1134
|
+
|
|
1135
|
+
if (timestampsWeNeed.has(timestampString)) {
|
|
1136
|
+
timestampsWeNeed.delete(timestampString);
|
|
1137
|
+
} else {
|
|
1138
|
+
const dbChange = deps.storage.readDbChange(ownerId, timestamp);
|
|
1139
|
+
if (dbChange == null) {
|
|
1140
|
+
storageError = true;
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
message = {
|
|
1144
|
+
timestamp: timestampBinary,
|
|
1145
|
+
change: dbChange,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (
|
|
1150
|
+
!output.canAddTimestampsRangeAndMessage(ourTimestamps, message)
|
|
1151
|
+
) {
|
|
1152
|
+
exceeded = true;
|
|
1153
|
+
endBound = timestamp;
|
|
1154
|
+
upper = index;
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
ourTimestamps.add(timestampBinary);
|
|
1159
|
+
if (message) output.addMessage(message);
|
|
1160
|
+
return true;
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
if (storageError) {
|
|
1164
|
+
return syncFail();
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const addRange = () => {
|
|
1168
|
+
coalesceSkipsBeforeAdd();
|
|
1169
|
+
output.addRange({
|
|
1170
|
+
type: RangeType.Timestamps,
|
|
1171
|
+
upperBound: endBound,
|
|
1172
|
+
timestamps: ourTimestamps,
|
|
1173
|
+
});
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
if (exceeded) {
|
|
1177
|
+
addRange();
|
|
1178
|
+
if (!addFingerprintForRemainingRange(upper)) {
|
|
1179
|
+
return syncFail();
|
|
1180
|
+
}
|
|
1181
|
+
return ok(output.unwrap());
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// If we need something, we have to respond with our timestamps.
|
|
1185
|
+
if (timestampsWeNeed.size > 0) {
|
|
1186
|
+
addRange();
|
|
1187
|
+
} else {
|
|
1188
|
+
skipRange(range);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
prevIndex = upper;
|
|
1196
|
+
prevUpperBound = currentUpperBound;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// If all ranges were skipped, there are no changes and sync is complete.
|
|
1200
|
+
const hasChange = output.getSize() > outputInitialSize;
|
|
1201
|
+
return ok(hasChange ? output.unwrap() : null);
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
const splitRange =
|
|
1205
|
+
(deps: StorageDep) =>
|
|
1206
|
+
(
|
|
1207
|
+
ownerId: BinaryOwnerId,
|
|
1208
|
+
lower: NonNegativeInt,
|
|
1209
|
+
upper: NonNegativeInt,
|
|
1210
|
+
upperBound: RangeUpperBound,
|
|
1211
|
+
buffer: ProtocolMessageBuffer,
|
|
1212
|
+
): void => {
|
|
1213
|
+
const itemCount = (upper - lower) as NonNegativeInt;
|
|
1214
|
+
const buckets = computeBalancedBuckets(itemCount);
|
|
1215
|
+
|
|
1216
|
+
if (!buckets.ok) {
|
|
1217
|
+
const range: TimestampsRangeWithTimestampsBuffer = {
|
|
1218
|
+
type: RangeType.Timestamps,
|
|
1219
|
+
upperBound,
|
|
1220
|
+
timestamps: createTimestampsBuffer(),
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
deps.storage.iterate(
|
|
1224
|
+
ownerId,
|
|
1225
|
+
0 as NonNegativeInt,
|
|
1226
|
+
itemCount,
|
|
1227
|
+
(timestamp) => {
|
|
1228
|
+
range.timestamps.add(binaryTimestampToTimestamp(timestamp));
|
|
1229
|
+
return true;
|
|
1230
|
+
},
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
buffer.addRange(range);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Check Storage.ts `fingerprint` and `fingerprintRanges` docs.
|
|
1238
|
+
const fingerprintRangesBuckets =
|
|
1239
|
+
lower === 0
|
|
1240
|
+
? buckets.value
|
|
1241
|
+
: [lower, ...buckets.value.map((b) => (b + lower) as NonNegativeInt)];
|
|
1242
|
+
|
|
1243
|
+
const fingerprintRanges = deps.storage.fingerprintRanges(
|
|
1244
|
+
ownerId,
|
|
1245
|
+
fingerprintRangesBuckets,
|
|
1246
|
+
upperBound,
|
|
1247
|
+
);
|
|
1248
|
+
// Errors are handled by the storage.
|
|
1249
|
+
if (fingerprintRanges == null) return;
|
|
1250
|
+
|
|
1251
|
+
const rangesToUse =
|
|
1252
|
+
lower > 0 ? fingerprintRanges.slice(1) : fingerprintRanges;
|
|
1253
|
+
|
|
1254
|
+
for (const range of rangesToUse) {
|
|
1255
|
+
buffer.addRange(range);
|
|
1256
|
+
}
|
|
1257
|
+
};
|
|
1258
|
+
|
|
1259
|
+
const decodeRanges = (buffer: Buffer): ReadonlyArray<Range> => {
|
|
1260
|
+
if (buffer.getLength() === 0) return [];
|
|
1261
|
+
|
|
1262
|
+
const rangesCount = decodeNonNegativeInt(buffer);
|
|
1263
|
+
if (rangesCount === 0) return [];
|
|
1264
|
+
|
|
1265
|
+
const timestampsCount = (rangesCount - 1) as NonNegativeInt;
|
|
1266
|
+
const timestamps = decodeTimestamps(buffer, timestampsCount);
|
|
1267
|
+
const rangeTypes: Array<RangeType> = [];
|
|
1268
|
+
|
|
1269
|
+
for (let i = 0; i < rangesCount; i++) {
|
|
1270
|
+
const rangeType = decodeNonNegativeInt(buffer);
|
|
1271
|
+
switch (rangeType) {
|
|
1272
|
+
case RangeType.Fingerprint:
|
|
1273
|
+
case RangeType.Skip:
|
|
1274
|
+
case RangeType.Timestamps:
|
|
1275
|
+
rangeTypes.push(rangeType as RangeType);
|
|
1276
|
+
break;
|
|
1277
|
+
default:
|
|
1278
|
+
throw new ProtocolDecodeError(`Invalid RangeType: ${rangeType}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const ranges: Array<Range> = [];
|
|
1283
|
+
|
|
1284
|
+
for (let i = 0; i < rangesCount; i++) {
|
|
1285
|
+
const upperBound =
|
|
1286
|
+
i < timestampsCount
|
|
1287
|
+
? timestampToBinaryTimestamp(timestamps[i])
|
|
1288
|
+
: InfiniteUpperBound;
|
|
1289
|
+
|
|
1290
|
+
const rangeType = rangeTypes[i];
|
|
1291
|
+
|
|
1292
|
+
switch (rangeType) {
|
|
1293
|
+
case RangeType.Skip:
|
|
1294
|
+
ranges.push({ type: RangeType.Skip, upperBound });
|
|
1295
|
+
break;
|
|
1296
|
+
|
|
1297
|
+
case RangeType.Fingerprint: {
|
|
1298
|
+
const fingerprint = buffer.shiftN(fingerprintSize) as Fingerprint;
|
|
1299
|
+
ranges.push({
|
|
1300
|
+
type: RangeType.Fingerprint,
|
|
1301
|
+
upperBound,
|
|
1302
|
+
fingerprint,
|
|
1303
|
+
});
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
case RangeType.Timestamps: {
|
|
1308
|
+
const timestamps = decodeTimestamps(buffer).map(
|
|
1309
|
+
timestampToBinaryTimestamp,
|
|
1310
|
+
);
|
|
1311
|
+
ranges.push({
|
|
1312
|
+
type: RangeType.Timestamps,
|
|
1313
|
+
upperBound,
|
|
1314
|
+
timestamps,
|
|
1315
|
+
});
|
|
1316
|
+
break;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
return ranges;
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
/** Decodes an array of sorted timestamps with delta-encoded millis. */
|
|
1325
|
+
const decodeTimestamps = (
|
|
1326
|
+
buffer: Buffer,
|
|
1327
|
+
length?: NonNegativeInt,
|
|
1328
|
+
): ReadonlyArray<Timestamp> => {
|
|
1329
|
+
length ??= decodeNonNegativeInt(buffer);
|
|
1330
|
+
|
|
1331
|
+
let previousMillis = 0 as Millis;
|
|
1332
|
+
|
|
1333
|
+
const millises: Array<Millis> = [];
|
|
1334
|
+
for (let i = 0; i < length; i++) {
|
|
1335
|
+
const deltaMillis = decodeNonNegativeInt(buffer);
|
|
1336
|
+
const millis = Millis.from(previousMillis + deltaMillis);
|
|
1337
|
+
if (!millis.ok) throw new Error(millis.error.type);
|
|
1338
|
+
millises.push(millis.value);
|
|
1339
|
+
previousMillis = millis.value;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const counters: Array<Counter> = [];
|
|
1343
|
+
let counterIndex = 0;
|
|
1344
|
+
while (counterIndex < length) {
|
|
1345
|
+
const counter = Counter.from(decodeNonNegativeInt(buffer));
|
|
1346
|
+
if (!counter.ok) throw new Error(counter.error.type);
|
|
1347
|
+
const runLength = decodeNonNegativeInt(buffer);
|
|
1348
|
+
for (let i = 0; i < runLength; i++) {
|
|
1349
|
+
counters.push(counter.value);
|
|
1350
|
+
counterIndex++;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const nodeIds: Array<NodeId> = [];
|
|
1355
|
+
let nodeIdIndex = 0;
|
|
1356
|
+
while (nodeIdIndex < length) {
|
|
1357
|
+
const nodeId = decodeNodeId(buffer);
|
|
1358
|
+
const runLength = decodeNonNegativeInt(buffer);
|
|
1359
|
+
for (let i = 0; i < runLength; i++) {
|
|
1360
|
+
nodeIds.push(nodeId);
|
|
1361
|
+
nodeIdIndex++;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const timestamps: Array<Timestamp> = [];
|
|
1366
|
+
for (let i = 0; i < length; i++) {
|
|
1367
|
+
timestamps.push({
|
|
1368
|
+
millis: millises[i],
|
|
1369
|
+
counter: counters[i],
|
|
1370
|
+
nodeId: nodeIds[i],
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
return timestamps;
|
|
1375
|
+
};
|
|
1376
|
+
|
|
1377
|
+
/** Binary representation of {@link Id}. */
|
|
1378
|
+
export type BinaryId = Uint8Array & Brand<"BinaryId">;
|
|
1379
|
+
|
|
1380
|
+
export const binaryIdLength = 16 as NonNegativeInt;
|
|
1381
|
+
|
|
1382
|
+
export const idToBinaryId = (id: Id): BinaryId =>
|
|
1383
|
+
base64Url256ToBytes(id) as BinaryId;
|
|
1384
|
+
|
|
1385
|
+
export const binaryIdToId = (binaryId: BinaryId): Id =>
|
|
1386
|
+
decodeId(createBuffer(binaryId));
|
|
1387
|
+
|
|
1388
|
+
/** Binary representation of {@link OwnerId}. */
|
|
1389
|
+
export type BinaryOwnerId = Uint8Array & Brand<"BinaryOwnerId">;
|
|
1390
|
+
|
|
1391
|
+
export const ownerIdToBinaryOwnerId = (ownerId: OwnerId): BinaryOwnerId =>
|
|
1392
|
+
base64Url256ToBytes(ownerId) as BinaryOwnerId;
|
|
1393
|
+
|
|
1394
|
+
export const binaryOwnerIdToOwnerId = (binaryOwnerId: BinaryOwnerId): OwnerId =>
|
|
1395
|
+
decodeOwnerId(createBuffer(binaryOwnerId));
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Base64Url string with maximum length of 256 characters. Encoding strings as
|
|
1399
|
+
* Base64UrlString saves up to 25% in size compared to regular strings.
|
|
1400
|
+
*/
|
|
1401
|
+
export const Base64Url256 = maxLength(256)(Base64Url);
|
|
1402
|
+
export type Base64Url256 = typeof Base64Url256.Type;
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Union type for all variants of Base64Url strings with limited length. All
|
|
1406
|
+
* these types use Base64Url alphabet and are < 256 characters.
|
|
1407
|
+
*/
|
|
1408
|
+
export type Base64Url256Variant = Base64Url256 | Id | NanoId | OwnerId;
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Alphabet used for Base64Url encoding. This is copied from the `nanoid`
|
|
1412
|
+
* library to avoid dependency on a specific version of `nanoid`.
|
|
1413
|
+
*/
|
|
1414
|
+
const urlAlphabet =
|
|
1415
|
+
"useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* Converts a Base64Url string to a Uint8Array for binary storage. This encoding
|
|
1419
|
+
* is more space-efficient than UTF-8 for Base64Url strings.
|
|
1420
|
+
*/
|
|
1421
|
+
export const base64Url256ToBytes = (
|
|
1422
|
+
string: Base64Url256Variant,
|
|
1423
|
+
): globalThis.Uint8Array => {
|
|
1424
|
+
const totalBits = string.length * 6; // 6 bits per character
|
|
1425
|
+
const byteLength = Math.ceil(totalBits / 8);
|
|
1426
|
+
const value = new globalThis.Uint8Array(byteLength);
|
|
1427
|
+
|
|
1428
|
+
let bitBuffer = 0;
|
|
1429
|
+
let bitsInBuffer = 0;
|
|
1430
|
+
let byteIndex = 0;
|
|
1431
|
+
|
|
1432
|
+
for (const char of string) {
|
|
1433
|
+
const charValue = urlAlphabet.indexOf(char);
|
|
1434
|
+
bitBuffer = (bitBuffer << 6) | charValue;
|
|
1435
|
+
bitsInBuffer += 6;
|
|
1436
|
+
while (bitsInBuffer >= 8) {
|
|
1437
|
+
bitsInBuffer -= 8;
|
|
1438
|
+
value[byteIndex++] = (bitBuffer >> bitsInBuffer) & 0xff;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
if (bitsInBuffer > 0 && byteIndex < byteLength) {
|
|
1443
|
+
value[byteIndex] = (bitBuffer << (8 - bitsInBuffer)) & 0xff;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return value;
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
export const decodeBase64Url256 = (
|
|
1450
|
+
buffer: Buffer,
|
|
1451
|
+
stringLength: number,
|
|
1452
|
+
): Base64Url256Variant => {
|
|
1453
|
+
const bytes = buffer.shiftN(
|
|
1454
|
+
Math.ceil((stringLength * 6) / 8) as NonNegativeInt,
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
let bitBuffer = 0;
|
|
1458
|
+
let bitsInBuffer = 0;
|
|
1459
|
+
let string = "";
|
|
1460
|
+
|
|
1461
|
+
for (const byte of bytes) {
|
|
1462
|
+
bitBuffer = (bitBuffer << 8) | byte;
|
|
1463
|
+
bitsInBuffer += 8;
|
|
1464
|
+
while (bitsInBuffer >= 6) {
|
|
1465
|
+
bitsInBuffer -= 6;
|
|
1466
|
+
if (string.length < stringLength) {
|
|
1467
|
+
const charValue = (bitBuffer >> bitsInBuffer) & 0x3f;
|
|
1468
|
+
if (charValue < 0 || charValue >= urlAlphabet.length) {
|
|
1469
|
+
throw new ProtocolDecodeError("invalid charValue");
|
|
1470
|
+
}
|
|
1471
|
+
string += urlAlphabet[charValue];
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const result = Base64Url256.from(string);
|
|
1477
|
+
if (!result.ok) throw new ProtocolDecodeError(result.error.type);
|
|
1478
|
+
|
|
1479
|
+
return result.value;
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
const decodeId = (buffer: Buffer): Id =>
|
|
1483
|
+
decodeBase64Url256(buffer, idTypeValueLength) as Id;
|
|
1484
|
+
|
|
1485
|
+
/** Not all 16 bytes are valid {@link OwnerId}. */
|
|
1486
|
+
const decodeOwnerId = (buffer: Buffer): OwnerId => decodeId(buffer) as OwnerId;
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Evolu uses MessagePack to handle all number variants except for
|
|
1490
|
+
* NonNegativeInt. For NonNegativeInt, Evolu provides more efficient encoding.
|
|
1491
|
+
*/
|
|
1492
|
+
export const encodeNumber = (buffer: Buffer, number: number): void => {
|
|
1493
|
+
buffer.extend(pack(number));
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
export const decodeNumber = (buffer: Buffer): number => {
|
|
1497
|
+
let number: unknown;
|
|
1498
|
+
let end: unknown;
|
|
1499
|
+
|
|
1500
|
+
unpackMultiple(buffer.unwrap(), (n, _, e) => {
|
|
1501
|
+
number = n;
|
|
1502
|
+
end = e;
|
|
1503
|
+
return false;
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
const endResult = NonNegativeInt.fromUnknown(end);
|
|
1507
|
+
if (!endResult.ok) throw new ProtocolDecodeError(endResult.error.type);
|
|
1508
|
+
|
|
1509
|
+
const numberResult = Number.fromUnknown(number);
|
|
1510
|
+
if (!numberResult.ok) throw new ProtocolDecodeError(numberResult.error.type);
|
|
1511
|
+
|
|
1512
|
+
buffer.shiftN(endResult.value);
|
|
1513
|
+
return numberResult.value;
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
export const binaryTimestampToFingerprint = (
|
|
1517
|
+
timestamp: BinaryTimestamp,
|
|
1518
|
+
): Fingerprint => {
|
|
1519
|
+
const hash = sha256(timestamp).slice(0, fingerprintSize);
|
|
1520
|
+
return hash as Fingerprint;
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* Encodes and encrypts a {@link DbChange} using the provided owner's encryption
|
|
1525
|
+
* key. Returns an encrypted binary representation as {@link EncryptedDbChange}.
|
|
1526
|
+
*/
|
|
1527
|
+
export const encodeAndEncryptDbChange =
|
|
1528
|
+
(deps: SymmetricCryptoDep) =>
|
|
1529
|
+
(change: DbChange, key: EncryptionKey): EncryptedDbChange => {
|
|
1530
|
+
const buffer = createBuffer();
|
|
1531
|
+
|
|
1532
|
+
encodeBase64Url256(buffer, change.table);
|
|
1533
|
+
|
|
1534
|
+
buffer.extend(idToBinaryId(change.id));
|
|
1535
|
+
|
|
1536
|
+
const entries = objectToEntries(change.values).map(
|
|
1537
|
+
([column, value]): [Base64Url256, SqliteValue] => {
|
|
1538
|
+
return [column, value];
|
|
1539
|
+
},
|
|
1540
|
+
);
|
|
1541
|
+
|
|
1542
|
+
encodeLength(buffer, entries);
|
|
1543
|
+
|
|
1544
|
+
for (const [column, value] of entries) {
|
|
1545
|
+
encodeBase64Url256(buffer, column);
|
|
1546
|
+
encodeSqliteValue(buffer, value);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const paddingLength = padmePaddingLength(buffer.getLength());
|
|
1550
|
+
// Add zero bytes as PADMÉ padding - these will be ignored during decoding.
|
|
1551
|
+
buffer.extend(new Uint8Array(paddingLength));
|
|
1552
|
+
|
|
1553
|
+
const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(
|
|
1554
|
+
buffer.unwrap(),
|
|
1555
|
+
key,
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
buffer.reset();
|
|
1559
|
+
buffer.extend(nonce);
|
|
1560
|
+
encodeLength(buffer, ciphertext);
|
|
1561
|
+
buffer.extend(ciphertext);
|
|
1562
|
+
|
|
1563
|
+
return buffer.unwrap() as EncryptedDbChange;
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Decrypts and decodes an {@link EncryptedDbChange} using the provided owner's
|
|
1568
|
+
* encryption key.
|
|
1569
|
+
*/
|
|
1570
|
+
export const decryptAndDecodeDbChange =
|
|
1571
|
+
(deps: SymmetricCryptoDep) =>
|
|
1572
|
+
(
|
|
1573
|
+
change: EncryptedDbChange,
|
|
1574
|
+
key: EncryptionKey,
|
|
1575
|
+
): Result<DbChange, SymmetricCryptoDecryptError | ProtocolInvalidDataError> =>
|
|
1576
|
+
tryDecodeProtocolData<DbChange, SymmetricCryptoDecryptError>(
|
|
1577
|
+
change,
|
|
1578
|
+
(buffer) => {
|
|
1579
|
+
const nonce = buffer.shiftN(deps.symmetricCrypto.nonceLength);
|
|
1580
|
+
|
|
1581
|
+
const ciphertextLength = decodeLength(buffer);
|
|
1582
|
+
const ciphertext = buffer.shiftN(ciphertextLength);
|
|
1583
|
+
|
|
1584
|
+
const plaintextBytes = deps.symmetricCrypto.decrypt(
|
|
1585
|
+
ciphertext,
|
|
1586
|
+
key,
|
|
1587
|
+
nonce,
|
|
1588
|
+
);
|
|
1589
|
+
if (!plaintextBytes.ok) return plaintextBytes;
|
|
1590
|
+
|
|
1591
|
+
buffer.reset();
|
|
1592
|
+
buffer.extend(plaintextBytes.value);
|
|
1593
|
+
|
|
1594
|
+
const table = decodeBase64Url256WithLength(buffer);
|
|
1595
|
+
const id = decodeId(buffer);
|
|
1596
|
+
|
|
1597
|
+
const length = decodeLength(buffer);
|
|
1598
|
+
const values = Object.create(null) as Record<string, SqliteValue>;
|
|
1599
|
+
|
|
1600
|
+
for (let i = 0; i < length; i++) {
|
|
1601
|
+
const column = decodeBase64Url256WithLength(buffer);
|
|
1602
|
+
const value = decodeSqliteValue(buffer);
|
|
1603
|
+
values[column] = value;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const dbChange = { table, id, values };
|
|
1607
|
+
|
|
1608
|
+
return ok(dbChange);
|
|
1609
|
+
},
|
|
1610
|
+
);
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* Encodes a non-negative integer into a variable-length integer format. It's
|
|
1614
|
+
* more efficient than encoding via {@link encodeNumber}.
|
|
1615
|
+
*
|
|
1616
|
+
* https://en.wikipedia.org/wiki/Variable-length_quantity
|
|
1617
|
+
*/
|
|
1618
|
+
export const encodeNonNegativeInt = (
|
|
1619
|
+
buffer: Buffer,
|
|
1620
|
+
int: NonNegativeInt,
|
|
1621
|
+
): void => {
|
|
1622
|
+
if (int === 0) {
|
|
1623
|
+
buffer.extend([0]);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
let remaining = BigInt(int);
|
|
1628
|
+
const bytes: Array<number> = [];
|
|
1629
|
+
|
|
1630
|
+
while (remaining !== 0n) {
|
|
1631
|
+
const byte = globalThis.Number(remaining & 127n);
|
|
1632
|
+
bytes.push(byte);
|
|
1633
|
+
remaining >>= 7n;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
for (let i = 0; i < bytes.length - 1; i++) {
|
|
1637
|
+
bytes[i] |= 128;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
buffer.extend(bytes);
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
/**
|
|
1644
|
+
* Decodes a non-negative integer from a variable-length integer format.
|
|
1645
|
+
*
|
|
1646
|
+
* https://en.wikipedia.org/wiki/Variable-length_quantity
|
|
1647
|
+
*/
|
|
1648
|
+
export const decodeNonNegativeInt = (buffer: Buffer): NonNegativeInt => {
|
|
1649
|
+
let result = 0n;
|
|
1650
|
+
let shift = 0n;
|
|
1651
|
+
let byte;
|
|
1652
|
+
|
|
1653
|
+
// 8 is the smallest required count
|
|
1654
|
+
for (let byteCount = 0; byteCount < 8; byteCount++) {
|
|
1655
|
+
byte = buffer.shift();
|
|
1656
|
+
result |= BigInt(byte & 127) << shift;
|
|
1657
|
+
if ((byte & 128) === 0) break;
|
|
1658
|
+
shift += 7n;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const int = NonNegativeInt.from(globalThis.Number(result));
|
|
1662
|
+
if (!int.ok) throw new ProtocolDecodeError(int.error.type);
|
|
1663
|
+
|
|
1664
|
+
return int.value;
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
export const encodeLength = (buffer: Buffer, value: ArrayLike<any>): void => {
|
|
1668
|
+
encodeNonNegativeInt(buffer, value.length as NonNegativeInt);
|
|
1669
|
+
};
|
|
1670
|
+
|
|
1671
|
+
export const decodeLength = decodeNonNegativeInt;
|
|
1672
|
+
|
|
1673
|
+
export const encodeString = (buffer: Buffer, value: string): void => {
|
|
1674
|
+
const bytes = utf8ToBytes(value);
|
|
1675
|
+
encodeLength(buffer, bytes);
|
|
1676
|
+
buffer.extend(bytes);
|
|
1677
|
+
};
|
|
1678
|
+
|
|
1679
|
+
export const decodeString = (buffer: Buffer): string => {
|
|
1680
|
+
const length = decodeLength(buffer);
|
|
1681
|
+
const bytes = buffer.shiftN(length);
|
|
1682
|
+
return bytesToUtf8(bytes);
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
export const encodeNodeId = (buffer: Buffer, nodeId: NodeId): void => {
|
|
1686
|
+
buffer.extend(hexToBytes(nodeId));
|
|
1687
|
+
};
|
|
1688
|
+
|
|
1689
|
+
export const decodeNodeId = (buffer: Buffer): NodeId => {
|
|
1690
|
+
const bytes = buffer.shiftN(8 as NonNegativeInt);
|
|
1691
|
+
return bytesToHex(bytes) as NodeId;
|
|
1692
|
+
};
|
|
1693
|
+
|
|
1694
|
+
export const encodeBase64Url256 = (
|
|
1695
|
+
buffer: Buffer,
|
|
1696
|
+
string: Base64Url256Variant,
|
|
1697
|
+
): void => {
|
|
1698
|
+
encodeLength(buffer, string);
|
|
1699
|
+
buffer.extend(base64Url256ToBytes(string));
|
|
1700
|
+
};
|
|
1701
|
+
|
|
1702
|
+
export const decodeBase64Url256WithLength = (buffer: Buffer): Base64Url256 => {
|
|
1703
|
+
const length = decodeLength(buffer);
|
|
1704
|
+
return decodeBase64Url256(buffer, length) as Base64Url256;
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
// Small ints are encoded into ProtocolValueType, saving one byte per int.
|
|
1708
|
+
const isSmallInt: Predicate<number> = (value: number) =>
|
|
1709
|
+
value >= 0 && value < 20;
|
|
1710
|
+
|
|
1711
|
+
export const ProtocolValueType = {
|
|
1712
|
+
// 0-19 small ints
|
|
1713
|
+
|
|
1714
|
+
// SQLite types
|
|
1715
|
+
String: 20 as NonNegativeInt,
|
|
1716
|
+
Number: 21 as NonNegativeInt,
|
|
1717
|
+
Null: 22 as NonNegativeInt,
|
|
1718
|
+
Binary: 23 as NonNegativeInt,
|
|
1719
|
+
// We can add more types for other DBs or anything else later.
|
|
1720
|
+
|
|
1721
|
+
// Optimized types
|
|
1722
|
+
Id: 30 as NonNegativeInt,
|
|
1723
|
+
Base64Url256: 31 as NonNegativeInt,
|
|
1724
|
+
NonNegativeInt: 32 as NonNegativeInt,
|
|
1725
|
+
Json: 33 as NonNegativeInt,
|
|
1726
|
+
|
|
1727
|
+
// new Date().toISOString() - 24 bytes
|
|
1728
|
+
// encoded with fixed length - 8 bytes
|
|
1729
|
+
// encode as NonNegativeInt - 6 bytes (additional 25% reduction)
|
|
1730
|
+
DateIsoWithNonNegativeTime: 34 as NonNegativeInt,
|
|
1731
|
+
DateIsoWithNegativeTime: 35 as NonNegativeInt, // 9 bytes
|
|
1732
|
+
|
|
1733
|
+
// TODO: Operations (from 40)
|
|
1734
|
+
// Increment, Decrement, Patch, whatever.
|
|
1735
|
+
} as const;
|
|
1736
|
+
|
|
1737
|
+
export const encodeSqliteValue = (buffer: Buffer, value: SqliteValue): void => {
|
|
1738
|
+
if (value === null) {
|
|
1739
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Null);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
switch (typeof value) {
|
|
1744
|
+
case "string": {
|
|
1745
|
+
const dateIsoString = DateIsoString.from(value);
|
|
1746
|
+
if (dateIsoString.ok) {
|
|
1747
|
+
const time = new Date(dateIsoString.value).getTime();
|
|
1748
|
+
if (NonNegativeInt.is(time)) {
|
|
1749
|
+
encodeNonNegativeInt(
|
|
1750
|
+
buffer,
|
|
1751
|
+
ProtocolValueType.DateIsoWithNonNegativeTime,
|
|
1752
|
+
);
|
|
1753
|
+
encodeNonNegativeInt(buffer, time);
|
|
1754
|
+
} else {
|
|
1755
|
+
encodeNonNegativeInt(
|
|
1756
|
+
buffer,
|
|
1757
|
+
ProtocolValueType.DateIsoWithNegativeTime,
|
|
1758
|
+
);
|
|
1759
|
+
encodeNumber(buffer, time);
|
|
1760
|
+
}
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
const base64Url256 = Base64Url256.from(value);
|
|
1765
|
+
if (base64Url256.ok) {
|
|
1766
|
+
if (base64Url256.value.length === idTypeValueLength) {
|
|
1767
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Id);
|
|
1768
|
+
buffer.extend(base64Url256ToBytes(base64Url256.value));
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Base64Url256);
|
|
1772
|
+
encodeBase64Url256(buffer, base64Url256.value);
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const jsonValue = JsonValueFromString.fromParent(value);
|
|
1777
|
+
if (jsonValue.ok) {
|
|
1778
|
+
const jsonBytes = pack(jsonValue.value);
|
|
1779
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Json);
|
|
1780
|
+
encodeLength(buffer, jsonBytes);
|
|
1781
|
+
buffer.extend(jsonBytes);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.String);
|
|
1786
|
+
encodeString(buffer, value);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
case "number": {
|
|
1791
|
+
if (NonNegativeInt.is(value)) {
|
|
1792
|
+
if (isSmallInt(value)) {
|
|
1793
|
+
encodeNonNegativeInt(buffer, value);
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.NonNegativeInt);
|
|
1797
|
+
encodeNonNegativeInt(buffer, value);
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Number);
|
|
1801
|
+
encodeNumber(buffer, value);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
encodeNonNegativeInt(buffer, ProtocolValueType.Binary);
|
|
1807
|
+
encodeLength(buffer, value);
|
|
1808
|
+
buffer.extend(value);
|
|
1809
|
+
};
|
|
1810
|
+
|
|
1811
|
+
export const decodeSqliteValue = (buffer: Buffer): SqliteValue => {
|
|
1812
|
+
const type = decodeNonNegativeInt(buffer);
|
|
1813
|
+
|
|
1814
|
+
if (isSmallInt(type)) {
|
|
1815
|
+
return type;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
switch (type) {
|
|
1819
|
+
case ProtocolValueType.String:
|
|
1820
|
+
return decodeString(buffer);
|
|
1821
|
+
case ProtocolValueType.Number:
|
|
1822
|
+
return decodeNumber(buffer);
|
|
1823
|
+
case ProtocolValueType.Null:
|
|
1824
|
+
return null;
|
|
1825
|
+
case ProtocolValueType.Binary: {
|
|
1826
|
+
const length = decodeLength(buffer);
|
|
1827
|
+
return buffer.shiftN(length);
|
|
1828
|
+
}
|
|
1829
|
+
case ProtocolValueType.Id: {
|
|
1830
|
+
return decodeId(buffer);
|
|
1831
|
+
}
|
|
1832
|
+
case ProtocolValueType.Base64Url256:
|
|
1833
|
+
return decodeBase64Url256WithLength(buffer);
|
|
1834
|
+
case ProtocolValueType.NonNegativeInt:
|
|
1835
|
+
return decodeNonNegativeInt(buffer);
|
|
1836
|
+
case ProtocolValueType.Json: {
|
|
1837
|
+
const length = decodeLength(buffer);
|
|
1838
|
+
const bytes = buffer.shiftN(length);
|
|
1839
|
+
return JSON.stringify(unpack(bytes));
|
|
1840
|
+
}
|
|
1841
|
+
case ProtocolValueType.DateIsoWithNonNegativeTime:
|
|
1842
|
+
case ProtocolValueType.DateIsoWithNegativeTime: {
|
|
1843
|
+
const time =
|
|
1844
|
+
type === ProtocolValueType.DateIsoWithNonNegativeTime
|
|
1845
|
+
? decodeNonNegativeInt(buffer)
|
|
1846
|
+
: decodeNumber(buffer);
|
|
1847
|
+
const dateIsoString = DateIsoString.fromParent(
|
|
1848
|
+
new Date(time).toISOString(),
|
|
1849
|
+
);
|
|
1850
|
+
if (!dateIsoString.ok)
|
|
1851
|
+
throw new ProtocolDecodeError(dateIsoString.error.type);
|
|
1852
|
+
return dateIsoString.value;
|
|
1853
|
+
}
|
|
1854
|
+
default:
|
|
1855
|
+
throw new ProtocolDecodeError("invalid ProtocolValueType");
|
|
1856
|
+
}
|
|
1857
|
+
};
|