@evolu/common 6.0.1-preview.2 → 6.0.1-preview.20

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