@evolu/common 6.0.1-preview.9 → 7.0.0

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