@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.
Files changed (154) hide show
  1. package/dist/src/Assert.d.ts +6 -3
  2. package/dist/src/Assert.d.ts.map +1 -1
  3. package/dist/src/Assert.js +7 -4
  4. package/dist/src/Brand.d.ts +75 -0
  5. package/dist/src/Brand.d.ts.map +1 -0
  6. package/dist/src/Brand.js +1 -0
  7. package/dist/src/Buffer.d.ts +1 -1
  8. package/dist/src/Buffer.d.ts.map +1 -1
  9. package/dist/src/Buffer.js +1 -1
  10. package/dist/src/CallbackRegistry.d.ts +53 -0
  11. package/dist/src/CallbackRegistry.d.ts.map +1 -0
  12. package/dist/src/CallbackRegistry.js +25 -0
  13. package/dist/src/Console.d.ts +31 -6
  14. package/dist/src/Console.d.ts.map +1 -1
  15. package/dist/src/Console.js +72 -9
  16. package/dist/src/Crypto.d.ts +61 -34
  17. package/dist/src/Crypto.d.ts.map +1 -1
  18. package/dist/src/Crypto.js +29 -42
  19. package/dist/src/Evolu/Db.d.ts +159 -66
  20. package/dist/src/Evolu/Db.d.ts.map +1 -1
  21. package/dist/src/Evolu/Db.js +300 -705
  22. package/dist/src/Evolu/Diff.d.ts +3 -3
  23. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  24. package/dist/src/Evolu/Diff.js +7 -5
  25. package/dist/src/Evolu/Evolu.d.ts +200 -115
  26. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  27. package/dist/src/Evolu/Evolu.js +287 -157
  28. package/dist/src/Evolu/Internal.d.ts +0 -2
  29. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  30. package/dist/src/Evolu/Internal.js +0 -2
  31. package/dist/src/Evolu/LocalAuth.d.ts +144 -0
  32. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  33. package/dist/src/Evolu/LocalAuth.js +171 -0
  34. package/dist/src/Evolu/Owner.d.ts +209 -124
  35. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  36. package/dist/src/Evolu/Owner.js +118 -104
  37. package/dist/src/Evolu/Platform.d.ts +9 -7
  38. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  39. package/dist/src/Evolu/Protocol.d.ts +256 -211
  40. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  41. package/dist/src/Evolu/Protocol.js +558 -359
  42. package/dist/src/Evolu/Public.d.ts +6 -8
  43. package/dist/src/Evolu/Public.d.ts.map +1 -1
  44. package/dist/src/Evolu/Public.js +2 -3
  45. package/dist/src/Evolu/PublicKysely.js +3 -3
  46. package/dist/src/Evolu/Query.d.ts +2 -1
  47. package/dist/src/Evolu/Query.d.ts.map +1 -1
  48. package/dist/src/Evolu/Relay.d.ts +72 -4
  49. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  50. package/dist/src/Evolu/Relay.js +124 -13
  51. package/dist/src/Evolu/Schema.d.ts +129 -73
  52. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  53. package/dist/src/Evolu/Schema.js +169 -89
  54. package/dist/src/Evolu/Storage.d.ts +159 -14
  55. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  56. package/dist/src/Evolu/Storage.js +42 -32
  57. package/dist/src/Evolu/Sync.d.ts +68 -13
  58. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  59. package/dist/src/Evolu/Sync.js +414 -20
  60. package/dist/src/Evolu/Timestamp.d.ts +35 -27
  61. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  62. package/dist/src/Evolu/Timestamp.js +27 -18
  63. package/dist/src/ManyToManyMap.d.ts +74 -10
  64. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  65. package/dist/src/ManyToManyMap.js +41 -6
  66. package/dist/src/Number.d.ts +2 -1
  67. package/dist/src/Number.d.ts.map +1 -1
  68. package/dist/src/Random.d.ts +3 -2
  69. package/dist/src/Random.d.ts.map +1 -1
  70. package/dist/src/RefCountedResourceManager.d.ts +119 -0
  71. package/dist/src/RefCountedResourceManager.d.ts.map +1 -0
  72. package/dist/src/RefCountedResourceManager.js +197 -0
  73. package/dist/src/Result.d.ts +176 -51
  74. package/dist/src/Result.d.ts.map +1 -1
  75. package/dist/src/Result.js +30 -241
  76. package/dist/src/Sqlite.d.ts +21 -4
  77. package/dist/src/Sqlite.d.ts.map +1 -1
  78. package/dist/src/Sqlite.js +50 -8
  79. package/dist/src/Task.d.ts +511 -0
  80. package/dist/src/Task.d.ts.map +1 -0
  81. package/dist/src/Task.js +410 -0
  82. package/dist/src/Time.d.ts +59 -0
  83. package/dist/src/Time.d.ts.map +1 -1
  84. package/dist/src/Time.js +87 -4
  85. package/dist/src/Type.d.ts +474 -327
  86. package/dist/src/Type.d.ts.map +1 -1
  87. package/dist/src/Type.js +502 -436
  88. package/dist/src/Types.d.ts +0 -74
  89. package/dist/src/Types.d.ts.map +1 -1
  90. package/dist/src/WebSocket.d.ts +5 -2
  91. package/dist/src/WebSocket.d.ts.map +1 -1
  92. package/dist/src/WebSocket.js +12 -13
  93. package/dist/src/Worker.d.ts +39 -11
  94. package/dist/src/Worker.d.ts.map +1 -1
  95. package/dist/src/Worker.js +22 -4
  96. package/dist/src/index.d.ts +3 -3
  97. package/dist/src/index.d.ts.map +1 -1
  98. package/dist/src/index.js +3 -3
  99. package/package.json +14 -13
  100. package/src/Assert.ts +8 -7
  101. package/src/Brand.ts +75 -0
  102. package/src/Buffer.ts +1 -1
  103. package/src/CallbackRegistry.ts +84 -0
  104. package/src/Console.ts +91 -11
  105. package/src/Crypto.ts +94 -79
  106. package/src/Evolu/Db.ts +528 -1027
  107. package/src/Evolu/Diff.ts +7 -5
  108. package/src/Evolu/Evolu.ts +682 -329
  109. package/src/Evolu/Internal.ts +0 -2
  110. package/src/Evolu/LocalAuth.ts +422 -0
  111. package/src/Evolu/Owner.ts +282 -227
  112. package/src/Evolu/Platform.ts +9 -9
  113. package/src/Evolu/Protocol.ts +788 -640
  114. package/src/Evolu/Public.ts +7 -14
  115. package/src/Evolu/PublicKysely.ts +3 -3
  116. package/src/Evolu/Query.ts +2 -1
  117. package/src/Evolu/Relay.ts +238 -18
  118. package/src/Evolu/Schema.ts +391 -191
  119. package/src/Evolu/Storage.ts +275 -55
  120. package/src/Evolu/Sync.ts +708 -37
  121. package/src/Evolu/Timestamp.ts +38 -35
  122. package/src/ManyToManyMap.ts +127 -24
  123. package/src/Number.ts +2 -6
  124. package/src/Random.ts +3 -2
  125. package/src/RefCountedResourceManager.ts +368 -0
  126. package/src/Result.ts +183 -53
  127. package/src/Sqlite.ts +59 -23
  128. package/src/Task.ts +779 -0
  129. package/src/Time.ts +168 -4
  130. package/src/Type.ts +708 -660
  131. package/src/Types.ts +0 -76
  132. package/src/WebSocket.ts +23 -17
  133. package/src/Worker.ts +72 -23
  134. package/src/index.ts +3 -3
  135. package/dist/src/Callbacks.d.ts +0 -20
  136. package/dist/src/Callbacks.d.ts.map +0 -1
  137. package/dist/src/Callbacks.js +0 -18
  138. package/dist/src/Evolu/Config.d.ts +0 -69
  139. package/dist/src/Evolu/Config.d.ts.map +0 -1
  140. package/dist/src/Evolu/Config.js +0 -9
  141. package/dist/src/Evolu/Kysely.d.ts +0 -6
  142. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  143. package/dist/src/Evolu/Kysely.js +0 -21
  144. package/dist/src/NanoId.d.ts +0 -27
  145. package/dist/src/NanoId.d.ts.map +0 -1
  146. package/dist/src/NanoId.js +0 -6
  147. package/dist/src/Promise.d.ts +0 -180
  148. package/dist/src/Promise.d.ts.map +0 -1
  149. package/dist/src/Promise.js +0 -176
  150. package/src/Callbacks.ts +0 -43
  151. package/src/Evolu/Config.ts +0 -83
  152. package/src/Evolu/Kysely.ts +0 -38
  153. package/src/NanoId.ts +0 -39
  154. package/src/Promise.ts +0 -295
package/src/Evolu/Sync.ts CHANGED
@@ -1,58 +1,729 @@
1
- import { ConsoleConfig, ConsoleDep } from "../Console.js";
2
- import { createWebSocket } from "../WebSocket.js";
3
- import { ProtocolMessage } from "./Protocol.js";
4
- import { Millis } from "./Timestamp.js";
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
- readonly send: (message: ProtocolMessage) => void;
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
- export type CreateSync = (deps: ConsoleDep) => (config: SyncConfig) => Sync;
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
- export interface CreateSyncDep {
17
- readonly createSync: CreateSync;
412
+ return ok();
413
+ },
414
+ };
415
+ };
416
+
417
+ interface GetSyncOwnerDep {
418
+ readonly getSyncOwner: (ownerId: OwnerId) => SyncOwner | null;
18
419
  }
19
420
 
20
- export interface SyncConfig extends ConsoleConfig {
21
- readonly syncUrl: string;
22
- readonly onOpen: (send: Sync["send"]) => void;
23
- readonly onMessage: (message: Uint8Array, send: Sync["send"]) => void;
421
+ export interface ClientStorage extends SqliteStorageBase, Storage {}
422
+
423
+ export interface ClientStorageDep {
424
+ readonly storage: ClientStorage;
24
425
  }
25
426
 
26
- export const createWebSocketSync: CreateSync = (_deps) => (config) => {
27
- const sync: Sync = {
28
- send: (message) => {
29
- /**
30
- * We don't need an in-memory queue; apps can be offline for a long time,
31
- * and mutations are stored in SQLite. Dropped CRDT messages are synced
32
- * when the web socket connection is open.
33
- */
34
- if (socket.getReadyState() !== "open") return;
35
- socket.send(message);
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
- const socket = createWebSocket(config.syncUrl, {
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
- return sync;
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
  *