@evolu/common 6.0.1-preview.2 → 6.0.1-preview.21
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 +159 -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 +209 -124
- package/dist/src/Evolu/Owner.d.ts.map +1 -1
- package/dist/src/Evolu/Owner.js +118 -104
- package/dist/src/Evolu/Platform.d.ts +9 -7
- package/dist/src/Evolu/Platform.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.d.ts +256 -211
- package/dist/src/Evolu/Protocol.d.ts.map +1 -1
- package/dist/src/Evolu/Protocol.js +558 -359
- 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 +72 -4
- package/dist/src/Evolu/Relay.d.ts.map +1 -1
- package/dist/src/Evolu/Relay.js +124 -13
- 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 +68 -13
- package/dist/src/Evolu/Sync.d.ts.map +1 -1
- package/dist/src/Evolu/Sync.js +414 -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 +176 -51
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Result.js +30 -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 +474 -327
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +502 -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 +528 -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 +282 -227
- package/src/Evolu/Platform.ts +9 -9
- package/src/Evolu/Protocol.ts +788 -640
- 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 +238 -18
- package/src/Evolu/Schema.ts +391 -191
- package/src/Evolu/Storage.ts +275 -55
- package/src/Evolu/Sync.ts +708 -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 +183 -53
- package/src/Sqlite.ts +59 -23
- package/src/Task.ts +779 -0
- package/src/Time.ts +168 -4
- package/src/Type.ts +708 -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/Sync.ts
CHANGED
|
@@ -1,58 +1,729 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { NonEmptyArray, NonEmptyReadonlyArray } from "../Array.js";
|
|
2
|
+
import { assert } from "../Assert.js";
|
|
3
|
+
import { Brand } from "../Brand.js";
|
|
4
|
+
import { ConsoleDep } from "../Console.js";
|
|
5
|
+
import {
|
|
6
|
+
RandomBytesDep,
|
|
7
|
+
SymmetricCryptoDecryptError,
|
|
8
|
+
SymmetricCryptoDep,
|
|
9
|
+
} from "../Crypto.js";
|
|
10
|
+
import { eqArrayNumber } from "../Eq.js";
|
|
11
|
+
import { createTransferableError, TransferableError } from "../Error.js";
|
|
12
|
+
import { constFalse } from "../Function.js";
|
|
13
|
+
import { objectToEntries } from "../Object.js";
|
|
14
|
+
import { RandomDep } from "../Random.js";
|
|
15
|
+
import { createRefCountedResourceManager } from "../RefCountedResourceManager.js";
|
|
16
|
+
import { ok, Result } from "../Result.js";
|
|
17
|
+
import { sql, SqliteDep, SqliteError, SqliteValue } from "../Sqlite.js";
|
|
18
|
+
import { AbortError, createMutex } from "../Task.js";
|
|
19
|
+
import { TimeDep } from "../Time.js";
|
|
20
|
+
import { IdBytes, idBytesToId, idToIdBytes } from "../Type.js";
|
|
21
|
+
import { CreateWebSocketDep, WebSocket } from "../WebSocket.js";
|
|
22
|
+
import type { PostMessageDep } from "./Db.js";
|
|
23
|
+
import {
|
|
24
|
+
AppOwner,
|
|
25
|
+
OwnerEncryptionKey,
|
|
26
|
+
OwnerId,
|
|
27
|
+
OwnerIdBytes,
|
|
28
|
+
ownerIdBytesToOwnerId,
|
|
29
|
+
ownerIdToOwnerIdBytes,
|
|
30
|
+
OwnerWriteKey,
|
|
31
|
+
ShardOwner,
|
|
32
|
+
SharedOwner,
|
|
33
|
+
SharedReadonlyOwner,
|
|
34
|
+
TransportConfig,
|
|
35
|
+
} from "./Owner.js";
|
|
36
|
+
import {
|
|
37
|
+
applyProtocolMessageAsClient,
|
|
38
|
+
createProtocolMessageForSync,
|
|
39
|
+
createProtocolMessageForUnsubscribe,
|
|
40
|
+
createProtocolMessageFromCrdtMessages,
|
|
41
|
+
decryptAndDecodeDbChange,
|
|
42
|
+
encodeAndEncryptDbChange,
|
|
43
|
+
ProtocolError,
|
|
44
|
+
ProtocolInvalidDataError,
|
|
45
|
+
ProtocolTimestampMismatchError,
|
|
46
|
+
SubscriptionFlags,
|
|
47
|
+
} from "./Protocol.js";
|
|
48
|
+
import { MutationChange } from "./Schema.js";
|
|
49
|
+
import {
|
|
50
|
+
CrdtMessage,
|
|
51
|
+
createSqliteStorageBase,
|
|
52
|
+
DbChange,
|
|
53
|
+
SqliteStorageBase,
|
|
54
|
+
Storage,
|
|
55
|
+
} from "./Storage.js";
|
|
56
|
+
import {
|
|
57
|
+
createInitialTimestamp,
|
|
58
|
+
Millis,
|
|
59
|
+
receiveTimestamp,
|
|
60
|
+
sendTimestamp,
|
|
61
|
+
Timestamp,
|
|
62
|
+
timestampBytesToTimestamp,
|
|
63
|
+
TimestampConfigDep,
|
|
64
|
+
TimestampCounterOverflowError,
|
|
65
|
+
TimestampDriftError,
|
|
66
|
+
TimestampTimeOutOfRangeError,
|
|
67
|
+
timestampToTimestampBytes,
|
|
68
|
+
timestampToTimestampString,
|
|
69
|
+
} from "./Timestamp.js";
|
|
5
70
|
|
|
6
|
-
export interface Sync {
|
|
7
|
-
|
|
71
|
+
export interface Sync extends Disposable {
|
|
72
|
+
/**
|
|
73
|
+
* Assigns or removes an owner to/from transports with reference counting.
|
|
74
|
+
*
|
|
75
|
+
* Owners are only "active" if assigned to at least one transport. Uses
|
|
76
|
+
* `owner.transports` or falls back to config transports. Multiple calls
|
|
77
|
+
* increment/decrement reference counts (useful for React Hooks).
|
|
78
|
+
*/
|
|
79
|
+
readonly useOwner: (use: boolean, owner: SyncOwner) => void;
|
|
80
|
+
|
|
81
|
+
readonly applyChanges: (
|
|
82
|
+
changes: NonEmptyReadonlyArray<MutationChange>,
|
|
83
|
+
) => Result<
|
|
84
|
+
void,
|
|
85
|
+
| SqliteError
|
|
86
|
+
| TimestampCounterOverflowError
|
|
87
|
+
| TimestampDriftError
|
|
88
|
+
| TimestampTimeOutOfRangeError
|
|
89
|
+
>;
|
|
8
90
|
}
|
|
9
91
|
|
|
10
92
|
export interface SyncDep {
|
|
11
93
|
readonly sync: Sync;
|
|
12
94
|
}
|
|
13
95
|
|
|
14
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Represents an owner for sync operations. This is a unified interface that
|
|
98
|
+
* abstracts over the specific owner types ({@link ShardOwner},
|
|
99
|
+
* {@link SharedOwner}, {@link SharedReadonlyOwner}) for the sync layer.
|
|
100
|
+
*
|
|
101
|
+
* The sync layer only needs the essential data for synchronization and doesn't
|
|
102
|
+
* need to distinguish between different owner types.
|
|
103
|
+
*/
|
|
104
|
+
export interface SyncOwner {
|
|
105
|
+
readonly id: OwnerId;
|
|
106
|
+
readonly encryptionKey: OwnerEncryptionKey;
|
|
107
|
+
/** Optional for read-only owners like {@link SharedReadonlyOwner}. */
|
|
108
|
+
readonly writeKey?: OwnerWriteKey;
|
|
109
|
+
readonly transports?: ReadonlyArray<TransportConfig>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface SyncConfig {
|
|
113
|
+
readonly appOwner: AppOwner;
|
|
114
|
+
|
|
115
|
+
readonly transports: ReadonlyArray<TransportConfig>;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Delay in milliseconds before disposing unused WebSocket connections.
|
|
119
|
+
* Defaults to 100ms.
|
|
120
|
+
*/
|
|
121
|
+
readonly disposalDelayMs?: number;
|
|
122
|
+
|
|
123
|
+
readonly onError: (
|
|
124
|
+
error:
|
|
125
|
+
| ProtocolError
|
|
126
|
+
| ProtocolInvalidDataError
|
|
127
|
+
| ProtocolTimestampMismatchError
|
|
128
|
+
| SqliteError
|
|
129
|
+
| SymmetricCryptoDecryptError
|
|
130
|
+
| TimestampCounterOverflowError
|
|
131
|
+
| TimestampDriftError
|
|
132
|
+
| TimestampTimeOutOfRangeError
|
|
133
|
+
| TransferableError,
|
|
134
|
+
) => void;
|
|
135
|
+
|
|
136
|
+
readonly onReceive: () => void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const createSync =
|
|
140
|
+
(
|
|
141
|
+
deps: ClockDep &
|
|
142
|
+
ConsoleDep &
|
|
143
|
+
CreateWebSocketDep &
|
|
144
|
+
PostMessageDep &
|
|
145
|
+
RandomBytesDep &
|
|
146
|
+
RandomDep &
|
|
147
|
+
SqliteDep &
|
|
148
|
+
SymmetricCryptoDep &
|
|
149
|
+
TimeDep &
|
|
150
|
+
TimestampConfigDep,
|
|
151
|
+
) =>
|
|
152
|
+
(config: SyncConfig): Result<Sync, SqliteError> => {
|
|
153
|
+
let isDisposed = false;
|
|
154
|
+
|
|
155
|
+
/** Returns owner data only if actively assigned to at least one transport. */
|
|
156
|
+
const getSyncOwner = (ownerId: OwnerId): SyncOwner | null => {
|
|
157
|
+
if (isDisposed) return null;
|
|
158
|
+
return transports.getConsumer(ownerId);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const storageResult = createClientStorage({
|
|
162
|
+
...deps,
|
|
163
|
+
getSyncOwner,
|
|
164
|
+
})(config);
|
|
165
|
+
|
|
166
|
+
if (!storageResult.ok) return storageResult;
|
|
167
|
+
const storage = storageResult.value;
|
|
168
|
+
|
|
169
|
+
const createResource = (transportConfig: TransportConfig): WebSocket => {
|
|
170
|
+
const transportKey = createTransportKey(transportConfig);
|
|
171
|
+
|
|
172
|
+
deps.console.log("[sync]", "createWebSocket", {
|
|
173
|
+
transportKey,
|
|
174
|
+
url: transportConfig.url,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return deps.createWebSocket(transportConfig.url, {
|
|
178
|
+
binaryType: "arraybuffer",
|
|
179
|
+
|
|
180
|
+
onOpen: () => {
|
|
181
|
+
if (isDisposed) return;
|
|
182
|
+
|
|
183
|
+
const webSocket = transports.getResource(transportKey);
|
|
184
|
+
if (!webSocket) return;
|
|
185
|
+
|
|
186
|
+
const ownerIds = transports.getConsumersForResource(transportKey);
|
|
187
|
+
deps.console.log("[sync]", "onOpen", { transportKey, ownerIds });
|
|
188
|
+
|
|
189
|
+
for (const ownerId of ownerIds) {
|
|
190
|
+
const message = createProtocolMessageForSync({ storage })(
|
|
191
|
+
ownerId,
|
|
192
|
+
SubscriptionFlags.Subscribe,
|
|
193
|
+
);
|
|
194
|
+
if (!message) continue;
|
|
195
|
+
deps.console.log("[sync]", "send", { message });
|
|
196
|
+
webSocket.send(message);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
onClose: (event) => {
|
|
201
|
+
deps.console.log("[sync]", "onClose", {
|
|
202
|
+
transportKey,
|
|
203
|
+
code: event.code,
|
|
204
|
+
reason: event.reason,
|
|
205
|
+
wasClean: event.wasClean,
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
onError: (error) => {
|
|
210
|
+
deps.console.warn("[sync]", "onError", { transportKey, error });
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
onMessage: (data: string | ArrayBuffer | Blob) => {
|
|
214
|
+
// Only handle ArrayBuffer data for sync messages
|
|
215
|
+
if (isDisposed || !(data instanceof ArrayBuffer)) return;
|
|
216
|
+
|
|
217
|
+
const webSocket = transports.getResource(transportKey);
|
|
218
|
+
if (!webSocket) return;
|
|
219
|
+
|
|
220
|
+
const input = new Uint8Array(data);
|
|
221
|
+
deps.console.log("[sync]", "onMessage", {
|
|
222
|
+
transportKey,
|
|
223
|
+
message: input,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
applyProtocolMessageAsClient({ storage })(input, {
|
|
227
|
+
// No write key, no sync (for a case when an owner was unused).
|
|
228
|
+
getWriteKey: (ownerId) => getSyncOwner(ownerId)?.writeKey ?? null,
|
|
229
|
+
})
|
|
230
|
+
.then((message) => {
|
|
231
|
+
if (!message.ok) {
|
|
232
|
+
config.onError(message.error);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
switch (message.value.type) {
|
|
237
|
+
case "response":
|
|
238
|
+
webSocket.send(message.value.message);
|
|
239
|
+
break;
|
|
240
|
+
case "no-response":
|
|
241
|
+
// Sync complete, no response needed
|
|
242
|
+
break;
|
|
243
|
+
case "broadcast":
|
|
244
|
+
// This was a broadcast message, don't affect sync counter
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
.catch((error: unknown) => {
|
|
249
|
+
config.onError(createTransferableError(error));
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const transports = createRefCountedResourceManager<
|
|
256
|
+
WebSocket,
|
|
257
|
+
TransportKey,
|
|
258
|
+
TransportConfig,
|
|
259
|
+
SyncOwner,
|
|
260
|
+
OwnerId
|
|
261
|
+
>({
|
|
262
|
+
createResource,
|
|
263
|
+
getResourceKey: createTransportKey,
|
|
264
|
+
getConsumerId: (owner) => owner.id,
|
|
265
|
+
disposalDelay: config.disposalDelayMs ?? 100,
|
|
266
|
+
|
|
267
|
+
onConsumerAdded: (owner, webSocket) => {
|
|
268
|
+
deps.console.log("[sync]", "onConsumerAdded", {
|
|
269
|
+
ownerId: owner.id,
|
|
270
|
+
isOpen: webSocket.isOpen(),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// The onOpen handler will sync it.
|
|
274
|
+
if (!webSocket.isOpen()) return;
|
|
275
|
+
const message = createProtocolMessageForSync({ storage })(
|
|
276
|
+
owner.id,
|
|
277
|
+
SubscriptionFlags.Subscribe,
|
|
278
|
+
);
|
|
279
|
+
if (message) webSocket.send(message);
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
onConsumerRemoved: (owner, webSocket) => {
|
|
283
|
+
deps.console.log("[sync]", "onConsumerRemoved", {
|
|
284
|
+
ownerId: owner.id,
|
|
285
|
+
isOpen: webSocket.isOpen(),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const message = createProtocolMessageForUnsubscribe(owner.id);
|
|
289
|
+
webSocket.send(message);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const sync: Sync = {
|
|
294
|
+
useOwner: (use, owner) => {
|
|
295
|
+
if (isDisposed) {
|
|
296
|
+
deps.console.warn(
|
|
297
|
+
"[sync]",
|
|
298
|
+
"useOwner called on disposed Sync instance",
|
|
299
|
+
{ owner },
|
|
300
|
+
);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
deps.console.log("[sync]", "useOwner", { use, owner });
|
|
305
|
+
const transportsToUse = owner.transports ?? config.transports;
|
|
306
|
+
|
|
307
|
+
if (use) {
|
|
308
|
+
transports.addConsumer(owner, transportsToUse);
|
|
309
|
+
} else {
|
|
310
|
+
const result = transports.removeConsumer(owner, transportsToUse);
|
|
311
|
+
|
|
312
|
+
if (!result.ok) {
|
|
313
|
+
deps.console.warn("[sync]", "Failed to remove consumer", {
|
|
314
|
+
transportsToUse,
|
|
315
|
+
ownerId: owner.id,
|
|
316
|
+
error: result.error,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
applyChanges: (changes) => {
|
|
323
|
+
deps.console.log("[sync]", "applyChanges", { changes });
|
|
324
|
+
|
|
325
|
+
let clockTimestamp = deps.clock.get();
|
|
326
|
+
const ownerMessages = new Map<OwnerId, NonEmptyArray<CrdtMessage>>();
|
|
327
|
+
|
|
328
|
+
for (const change of changes) {
|
|
329
|
+
const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
|
|
330
|
+
if (!nextTimestamp.ok) return nextTimestamp;
|
|
331
|
+
clockTimestamp = nextTimestamp.value;
|
|
332
|
+
|
|
333
|
+
const { ownerId = config.appOwner.id, ...dbChange } = change;
|
|
334
|
+
const message = { timestamp: clockTimestamp, change: dbChange };
|
|
335
|
+
|
|
336
|
+
const messages = ownerMessages.get(ownerId);
|
|
337
|
+
if (messages) messages.push(message);
|
|
338
|
+
else ownerMessages.set(ownerId, [message]);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const [ownerId, messages] of ownerMessages) {
|
|
342
|
+
const result = applyMessages({ ...deps, storage })(ownerId, messages);
|
|
343
|
+
if (!result.ok) return result;
|
|
344
|
+
|
|
345
|
+
const owner = getSyncOwner(ownerId);
|
|
346
|
+
if (!owner?.writeKey) continue;
|
|
347
|
+
|
|
348
|
+
const message = createProtocolMessageFromCrdtMessages(deps)(
|
|
349
|
+
{
|
|
350
|
+
id: owner.id,
|
|
351
|
+
encryptionKey: owner.encryptionKey,
|
|
352
|
+
writeKey: owner.writeKey,
|
|
353
|
+
},
|
|
354
|
+
messages,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const transportsToUse = owner.transports ?? config.transports;
|
|
358
|
+
|
|
359
|
+
// Send message to all transports for this owner
|
|
360
|
+
for (const transportConfig of transportsToUse) {
|
|
361
|
+
const transportKey = createTransportKey(transportConfig);
|
|
362
|
+
|
|
363
|
+
const webSocket = transports.getResource(transportKey);
|
|
364
|
+
if (!webSocket) continue;
|
|
365
|
+
|
|
366
|
+
if (webSocket.isOpen()) {
|
|
367
|
+
deps.console.log("[sync]", "send", { transportKey, message });
|
|
368
|
+
webSocket.send(message);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return deps.clock.save(clockTimestamp);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
[Symbol.dispose]: () => {
|
|
377
|
+
if (isDisposed) return;
|
|
378
|
+
isDisposed = true;
|
|
379
|
+
transports[Symbol.dispose]();
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
return ok(sync);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
export interface ClockDep {
|
|
387
|
+
readonly clock: Clock;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// HLC
|
|
391
|
+
export interface Clock {
|
|
392
|
+
readonly get: () => Timestamp;
|
|
393
|
+
readonly save: (timestamp: Timestamp) => Result<void, SqliteError>;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export const createClock =
|
|
397
|
+
(deps: RandomBytesDep & SqliteDep) =>
|
|
398
|
+
(initialTimestamp = createInitialTimestamp(deps)): Clock => {
|
|
399
|
+
let currentTimestamp = initialTimestamp;
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
get: () => currentTimestamp,
|
|
403
|
+
save: (timestamp) => {
|
|
404
|
+
currentTimestamp = timestamp;
|
|
405
|
+
|
|
406
|
+
const timestampString = timestampToTimestampString(timestamp);
|
|
407
|
+
const result = deps.sqlite.exec(sql.prepared`
|
|
408
|
+
update evolu_config set "clock" = ${timestampString};
|
|
409
|
+
`);
|
|
410
|
+
if (!result.ok) return result;
|
|
15
411
|
|
|
16
|
-
|
|
17
|
-
|
|
412
|
+
return ok();
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
interface GetSyncOwnerDep {
|
|
418
|
+
readonly getSyncOwner: (ownerId: OwnerId) => SyncOwner | null;
|
|
18
419
|
}
|
|
19
420
|
|
|
20
|
-
export interface
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
readonly
|
|
421
|
+
export interface ClientStorage extends SqliteStorageBase, Storage {}
|
|
422
|
+
|
|
423
|
+
export interface ClientStorageDep {
|
|
424
|
+
readonly storage: ClientStorage;
|
|
24
425
|
}
|
|
25
426
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
427
|
+
const createClientStorage =
|
|
428
|
+
(
|
|
429
|
+
deps: ClockDep &
|
|
430
|
+
GetSyncOwnerDep &
|
|
431
|
+
RandomDep &
|
|
432
|
+
SqliteDep &
|
|
433
|
+
SymmetricCryptoDep &
|
|
434
|
+
TimeDep &
|
|
435
|
+
TimestampConfigDep &
|
|
436
|
+
PostMessageDep,
|
|
437
|
+
) =>
|
|
438
|
+
(config: {
|
|
439
|
+
onError: (
|
|
440
|
+
error:
|
|
441
|
+
| ProtocolInvalidDataError
|
|
442
|
+
| ProtocolTimestampMismatchError
|
|
443
|
+
| SqliteError
|
|
444
|
+
| SymmetricCryptoDecryptError
|
|
445
|
+
| TimestampCounterOverflowError
|
|
446
|
+
| TimestampDriftError
|
|
447
|
+
| TimestampTimeOutOfRangeError,
|
|
448
|
+
) => void;
|
|
449
|
+
onReceive: () => void;
|
|
450
|
+
}): Result<ClientStorage, SqliteError> => {
|
|
451
|
+
const sqliteStorageBase = createSqliteStorageBase(deps)({
|
|
452
|
+
onStorageError: config.onError,
|
|
453
|
+
});
|
|
454
|
+
if (!sqliteStorageBase.ok) return sqliteStorageBase;
|
|
455
|
+
|
|
456
|
+
const mutex = createMutex();
|
|
457
|
+
|
|
458
|
+
const storage: ClientStorage = {
|
|
459
|
+
...sqliteStorageBase.value,
|
|
460
|
+
|
|
461
|
+
validateWriteKey: constFalse,
|
|
462
|
+
setWriteKey: constFalse,
|
|
463
|
+
|
|
464
|
+
writeMessages: async (ownerIdBytes, encryptedMessages) => {
|
|
465
|
+
const writeResult = await mutex.withLock<
|
|
466
|
+
boolean,
|
|
467
|
+
| AbortError
|
|
468
|
+
| ProtocolInvalidDataError
|
|
469
|
+
| ProtocolTimestampMismatchError
|
|
470
|
+
| SqliteError
|
|
471
|
+
| SymmetricCryptoDecryptError
|
|
472
|
+
| TimestampCounterOverflowError
|
|
473
|
+
| TimestampDriftError
|
|
474
|
+
| TimestampTimeOutOfRangeError
|
|
475
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
476
|
+
>(async () => {
|
|
477
|
+
const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
|
|
478
|
+
const owner = deps.getSyncOwner(ownerId);
|
|
479
|
+
// Owner can be removed during syncing.
|
|
480
|
+
if (!owner) return ok(false);
|
|
481
|
+
|
|
482
|
+
const messages: Array<CrdtMessage> = [];
|
|
483
|
+
|
|
484
|
+
for (const message of encryptedMessages) {
|
|
485
|
+
const change = decryptAndDecodeDbChange(deps)(
|
|
486
|
+
message,
|
|
487
|
+
owner.encryptionKey,
|
|
488
|
+
);
|
|
489
|
+
if (!change.ok) return change;
|
|
490
|
+
|
|
491
|
+
messages.push({
|
|
492
|
+
timestamp: message.timestamp,
|
|
493
|
+
change: change.value,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const transaction = deps.sqlite.transaction(() => {
|
|
498
|
+
let clockTimestamp = deps.clock.get();
|
|
499
|
+
|
|
500
|
+
for (const message of messages) {
|
|
501
|
+
const nextTimestamp = receiveTimestamp(deps)(
|
|
502
|
+
clockTimestamp,
|
|
503
|
+
message.timestamp,
|
|
504
|
+
);
|
|
505
|
+
if (!nextTimestamp.ok) return nextTimestamp;
|
|
506
|
+
|
|
507
|
+
clockTimestamp = nextTimestamp.value;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const applyMessagesResult = applyMessages({ ...deps, storage })(
|
|
511
|
+
owner.id,
|
|
512
|
+
messages,
|
|
513
|
+
);
|
|
514
|
+
if (!applyMessagesResult.ok) return applyMessagesResult;
|
|
515
|
+
|
|
516
|
+
// // Apply local mutations atomically with approved messages
|
|
517
|
+
// for (const change of localMutations) {
|
|
518
|
+
// const result = applyLocalOnlyChange(deps)(change);
|
|
519
|
+
// if (!result.ok) return result;
|
|
520
|
+
// }
|
|
521
|
+
|
|
522
|
+
return deps.clock.save(clockTimestamp);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
if (!transaction.ok) return transaction;
|
|
526
|
+
|
|
527
|
+
return ok(true);
|
|
528
|
+
})();
|
|
529
|
+
|
|
530
|
+
if (!writeResult.ok) {
|
|
531
|
+
if (writeResult.error.type !== "AbortError") {
|
|
532
|
+
config.onError(writeResult.error);
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
config.onReceive();
|
|
538
|
+
|
|
539
|
+
return true;
|
|
540
|
+
},
|
|
541
|
+
|
|
542
|
+
readDbChange: (ownerId, timestamp) => {
|
|
543
|
+
const owner = deps.getSyncOwner(ownerIdBytesToOwnerId(ownerId));
|
|
544
|
+
// Owner can be removed to stop syncing.
|
|
545
|
+
if (!owner) return null;
|
|
546
|
+
|
|
547
|
+
const result = deps.sqlite.exec<{
|
|
548
|
+
table: string;
|
|
549
|
+
id: IdBytes;
|
|
550
|
+
column: string;
|
|
551
|
+
value: SqliteValue;
|
|
552
|
+
}>(sql`
|
|
553
|
+
select "table", "id", "column", "value"
|
|
554
|
+
from evolu_history
|
|
555
|
+
where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
|
|
556
|
+
`);
|
|
557
|
+
if (!result.ok) {
|
|
558
|
+
config.onError(result.error);
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const { rows } = result.value;
|
|
563
|
+
assert(rows.length > 0, "Rows must not be empty");
|
|
564
|
+
|
|
565
|
+
const { table, id } = rows[0];
|
|
566
|
+
const values: Record<string, SqliteValue> = {};
|
|
567
|
+
|
|
568
|
+
for (const r of rows) {
|
|
569
|
+
assert(r.table === table, "All rows must have the same table");
|
|
570
|
+
assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
|
|
571
|
+
values[r.column] = r.value;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const message: CrdtMessage = {
|
|
575
|
+
timestamp: timestampBytesToTimestamp(timestamp),
|
|
576
|
+
change: {
|
|
577
|
+
table: rows[0].table,
|
|
578
|
+
id: idBytesToId(rows[0].id),
|
|
579
|
+
values,
|
|
580
|
+
},
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
return encodeAndEncryptDbChange(deps)(message, owner.encryptionKey);
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
return ok(storage);
|
|
37
588
|
};
|
|
38
589
|
|
|
39
|
-
|
|
40
|
-
binaryType: "arraybuffer",
|
|
41
|
-
onOpen: () => {
|
|
42
|
-
config.onOpen(sync.send);
|
|
43
|
-
},
|
|
44
|
-
onMessage: (data) => {
|
|
45
|
-
if (data instanceof ArrayBuffer) {
|
|
46
|
-
const messages = new Uint8Array(data);
|
|
47
|
-
config.onMessage(messages, sync.send);
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
});
|
|
590
|
+
type TransportKey = string & Brand<"TransportKey">;
|
|
51
591
|
|
|
52
|
-
|
|
592
|
+
/** Creates a unique identifier for a transport configuration. */
|
|
593
|
+
const createTransportKey = (transportConfig: TransportConfig): TransportKey => {
|
|
594
|
+
return `${transportConfig.type}:${transportConfig.url}` as TransportKey;
|
|
53
595
|
};
|
|
54
596
|
|
|
597
|
+
export const applyLocalOnlyChange =
|
|
598
|
+
(deps: SqliteDep & TimeDep) =>
|
|
599
|
+
(change: MutationChange): Result<void, SqliteError> => {
|
|
600
|
+
const dbChange: DbChange = {
|
|
601
|
+
table: change.table,
|
|
602
|
+
id: change.id,
|
|
603
|
+
values: change.values,
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const isDeletion =
|
|
607
|
+
"isDeleted" in dbChange.values && dbChange.values.isDeleted === 1;
|
|
608
|
+
|
|
609
|
+
if (isDeletion) {
|
|
610
|
+
const result = deps.sqlite.exec(sql`
|
|
611
|
+
delete from ${sql.identifier(dbChange.table)}
|
|
612
|
+
where id = ${dbChange.id};
|
|
613
|
+
`);
|
|
614
|
+
if (!result.ok) return result;
|
|
615
|
+
} else {
|
|
616
|
+
const date = new Date(deps.time.now()).toISOString();
|
|
617
|
+
|
|
618
|
+
for (const [column, value] of objectToEntries(dbChange.values)) {
|
|
619
|
+
const result = deps.sqlite.exec(sql.prepared`
|
|
620
|
+
insert into ${sql.identifier(dbChange.table)}
|
|
621
|
+
("id", ${sql.identifier(column)}, createdAt, updatedAt)
|
|
622
|
+
values (${dbChange.id}, ${value}, ${date}, ${date})
|
|
623
|
+
on conflict ("id") do update
|
|
624
|
+
set
|
|
625
|
+
${sql.identifier(column)} = ${value},
|
|
626
|
+
updatedAt = ${date};
|
|
627
|
+
`);
|
|
628
|
+
if (!result.ok) return result;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return ok();
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export const applyMessages =
|
|
636
|
+
(deps: ClientStorageDep & ClockDep & RandomDep & SqliteDep) =>
|
|
637
|
+
(
|
|
638
|
+
ownerId: OwnerId,
|
|
639
|
+
messages: ReadonlyArray<CrdtMessage>,
|
|
640
|
+
): Result<void, SqliteError> => {
|
|
641
|
+
const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
|
|
642
|
+
|
|
643
|
+
for (const message of messages) {
|
|
644
|
+
const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message);
|
|
645
|
+
if (!result1.ok) return result1;
|
|
646
|
+
|
|
647
|
+
const result2 = applyMessageToTimestampAndHistoryTables(deps)(
|
|
648
|
+
ownerIdBytes,
|
|
649
|
+
message,
|
|
650
|
+
);
|
|
651
|
+
if (!result2.ok) return result2;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return ok();
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const applyMessageToAppTable =
|
|
658
|
+
(deps: SqliteDep) =>
|
|
659
|
+
(ownerId: OwnerIdBytes, message: CrdtMessage): Result<void, SqliteError> => {
|
|
660
|
+
const timestamp = timestampToTimestampBytes(message.timestamp);
|
|
661
|
+
const updatedAt = new Date(message.timestamp.millis).toISOString();
|
|
662
|
+
|
|
663
|
+
for (const [column, value] of objectToEntries(message.change.values)) {
|
|
664
|
+
const result = deps.sqlite.exec(sql.prepared`
|
|
665
|
+
with
|
|
666
|
+
existingTimestamp as (
|
|
667
|
+
select 1
|
|
668
|
+
from evolu_history
|
|
669
|
+
where
|
|
670
|
+
"ownerId" = ${ownerId}
|
|
671
|
+
and "table" = ${message.change.table}
|
|
672
|
+
and "id" = ${idToIdBytes(message.change.id)}
|
|
673
|
+
and "column" = ${column}
|
|
674
|
+
and "timestamp" >= ${timestamp}
|
|
675
|
+
limit 1
|
|
676
|
+
)
|
|
677
|
+
insert into ${sql.identifier(message.change.table)}
|
|
678
|
+
("id", ${sql.identifier(column)}, updatedAt)
|
|
679
|
+
select ${message.change.id}, ${value}, ${updatedAt}
|
|
680
|
+
where not exists (select 1 from existingTimestamp)
|
|
681
|
+
on conflict ("id") do update
|
|
682
|
+
set
|
|
683
|
+
${sql.identifier(column)} = ${value},
|
|
684
|
+
updatedAt = ${updatedAt}
|
|
685
|
+
where not exists (select 1 from existingTimestamp);
|
|
686
|
+
`);
|
|
687
|
+
|
|
688
|
+
if (!result.ok) return result;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return ok();
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
export const applyMessageToTimestampAndHistoryTables =
|
|
695
|
+
(deps: ClientStorageDep & SqliteDep) =>
|
|
696
|
+
(ownerId: OwnerIdBytes, message: CrdtMessage): Result<void, SqliteError> => {
|
|
697
|
+
const timestamp = timestampToTimestampBytes(message.timestamp);
|
|
698
|
+
const id = idToIdBytes(message.change.id);
|
|
699
|
+
|
|
700
|
+
const result = deps.storage.insertTimestamp(ownerId, timestamp);
|
|
701
|
+
if (!result.ok) return result;
|
|
702
|
+
|
|
703
|
+
for (const [column, value] of Object.entries(message.change.values)) {
|
|
704
|
+
const result = deps.sqlite.exec(sql.prepared`
|
|
705
|
+
insert into evolu_history
|
|
706
|
+
("ownerId", "table", "id", "column", "value", "timestamp")
|
|
707
|
+
values
|
|
708
|
+
(
|
|
709
|
+
${ownerId},
|
|
710
|
+
${message.change.table},
|
|
711
|
+
${id},
|
|
712
|
+
${column},
|
|
713
|
+
${value},
|
|
714
|
+
${timestamp}
|
|
715
|
+
)
|
|
716
|
+
on conflict do nothing;
|
|
717
|
+
`);
|
|
718
|
+
if (!result.ok) return result;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return ok();
|
|
722
|
+
};
|
|
723
|
+
|
|
55
724
|
/**
|
|
725
|
+
* TODO: Rework for the new owners API.
|
|
726
|
+
*
|
|
56
727
|
* The possible states of a synchronization process. The `SyncState` can be one
|
|
57
728
|
* of the following:
|
|
58
729
|
*
|