@evolu/common 6.0.1-preview.3 → 6.0.1-preview.30

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