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

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 +69 -5
  2. package/dist/src/Array.d.ts.map +1 -1
  3. package/dist/src/Array.js +64 -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 +161 -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 +273 -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 +238 -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 +240 -26
  61. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  62. package/dist/src/Evolu/Storage.js +189 -91
  63. package/dist/src/Evolu/Sync.d.ts +67 -13
  64. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  65. package/dist/src/Evolu/Sync.js +441 -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 +622 -340
  103. package/dist/src/Type.d.ts.map +1 -1
  104. package/dist/src/Type.js +666 -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 +90 -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 +517 -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 +355 -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 +437 -93
  137. package/src/Evolu/Schema.ts +391 -191
  138. package/src/Evolu/Storage.ts +532 -135
  139. package/src/Evolu/Sync.ts +766 -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 +1084 -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
@@ -1,29 +1,450 @@
1
- import { createWebSocket } from "../WebSocket.js";
2
- export const createWebSocketSync = (_deps) => (config) => {
1
+ import { firstInArray, isNonEmptyReadonlyArray, } from "../Array.js";
2
+ import { assert } from "../Assert.js";
3
+ import { eqArrayNumber } from "../Eq.js";
4
+ import { createTransferableError } from "../Error.js";
5
+ import { constFalse, constTrue } from "../Function.js";
6
+ import { objectToEntries } from "../Object.js";
7
+ import { createResources } from "../Resources.js";
8
+ import { err, ok } from "../Result.js";
9
+ import { sql } from "../Sqlite.js";
10
+ import { createMutex } from "../Task.js";
11
+ import { idBytesToId, idToIdBytes } from "../Type.js";
12
+ import { ownerIdBytesToOwnerId, ownerIdToOwnerIdBytes, } from "./Owner.js";
13
+ import { applyProtocolMessageAsClient, createProtocolMessageForSync, createProtocolMessageForUnsubscribe, createProtocolMessageFromCrdtMessages, decryptAndDecodeDbChange, encodeAndEncryptDbChange, SubscriptionFlags, } from "./Protocol.js";
14
+ import { createBaseSqliteStorage, getOwnerUsage, getTimestampInsertStrategy, updateOwnerUsage, } from "./Storage.js";
15
+ import { createInitialTimestamp, receiveTimestamp, sendTimestamp, timestampBytesToTimestamp, timestampToTimestampBytes, timestampToTimestampString, } from "./Timestamp.js";
16
+ export const createSync = (deps) => (config) => {
17
+ let isDisposed = false;
18
+ /** Returns owner data only if actively assigned to at least one transport. */
19
+ const getSyncOwner = (ownerId) => {
20
+ if (isDisposed)
21
+ return null;
22
+ return transports.getConsumer(ownerId);
23
+ };
24
+ const storageResult = createClientStorage({
25
+ ...deps,
26
+ getSyncOwner,
27
+ })(config);
28
+ if (!storageResult.ok)
29
+ return storageResult;
30
+ const storage = storageResult.value;
31
+ const createResource = (transportConfig) => {
32
+ const transportKey = createTransportKey(transportConfig);
33
+ deps.console.log("[sync]", "createWebSocket", {
34
+ transportKey,
35
+ url: transportConfig.url,
36
+ });
37
+ return deps.createWebSocket(transportConfig.url, {
38
+ binaryType: "arraybuffer",
39
+ onOpen: () => {
40
+ if (isDisposed)
41
+ return;
42
+ const webSocket = transports.getResource(transportKey);
43
+ if (!webSocket)
44
+ return;
45
+ const ownerIds = transports.getConsumersForResource(transportKey);
46
+ deps.console.log("[sync]", "onOpen", { transportKey, ownerIds });
47
+ for (const ownerId of ownerIds) {
48
+ const message = createProtocolMessageForSync({ storage })(ownerId, SubscriptionFlags.Subscribe);
49
+ if (!message)
50
+ continue;
51
+ deps.console.log("[sync]", "send", { message });
52
+ webSocket.send(message);
53
+ }
54
+ },
55
+ onClose: (event) => {
56
+ deps.console.log("[sync]", "onClose", {
57
+ transportKey,
58
+ code: event.code,
59
+ reason: event.reason,
60
+ wasClean: event.wasClean,
61
+ });
62
+ },
63
+ onError: (error) => {
64
+ deps.console.warn("[sync]", "onError", { transportKey, error });
65
+ },
66
+ onMessage: (data) => {
67
+ // Only handle ArrayBuffer data for sync messages
68
+ if (isDisposed || !(data instanceof ArrayBuffer))
69
+ return;
70
+ const webSocket = transports.getResource(transportKey);
71
+ if (!webSocket)
72
+ return;
73
+ const input = new Uint8Array(data);
74
+ deps.console.log("[sync]", "onMessage", {
75
+ transportKey,
76
+ message: input,
77
+ });
78
+ applyProtocolMessageAsClient({ storage })(input, {
79
+ // No write key, no sync (for a case when an owner was unused).
80
+ getWriteKey: (ownerId) => getSyncOwner(ownerId)?.writeKey ?? null,
81
+ })
82
+ .then((message) => {
83
+ if (!message.ok) {
84
+ config.onError(message.error);
85
+ return;
86
+ }
87
+ switch (message.value.type) {
88
+ case "response":
89
+ webSocket.send(message.value.message);
90
+ break;
91
+ case "no-response":
92
+ // Sync complete, no response needed
93
+ break;
94
+ case "broadcast":
95
+ // This was a broadcast message, don't affect sync counter
96
+ break;
97
+ }
98
+ })
99
+ .catch((error) => {
100
+ config.onError(createTransferableError(error));
101
+ });
102
+ },
103
+ });
104
+ };
105
+ const transports = createResources({
106
+ createResource,
107
+ getResourceKey: createTransportKey,
108
+ getConsumerId: (owner) => owner.id,
109
+ disposalDelay: config.disposalDelayMs ?? 100,
110
+ onConsumerAdded: (owner, webSocket) => {
111
+ deps.console.log("[sync]", "onConsumerAdded", {
112
+ ownerId: owner.id,
113
+ isOpen: webSocket.isOpen(),
114
+ });
115
+ // The onOpen handler will sync it.
116
+ if (!webSocket.isOpen())
117
+ return;
118
+ const message = createProtocolMessageForSync({ storage })(owner.id, SubscriptionFlags.Subscribe);
119
+ if (message)
120
+ webSocket.send(message);
121
+ },
122
+ onConsumerRemoved: (owner, webSocket) => {
123
+ deps.console.log("[sync]", "onConsumerRemoved", {
124
+ ownerId: owner.id,
125
+ isOpen: webSocket.isOpen(),
126
+ });
127
+ const message = createProtocolMessageForUnsubscribe(owner.id);
128
+ webSocket.send(message);
129
+ },
130
+ });
3
131
  const sync = {
4
- send: (message) => {
5
- /**
6
- * We don't need an in-memory queue; apps can be offline for a long time,
7
- * and mutations are stored in SQLite. Dropped CRDT messages are synced
8
- * when the web socket connection is open.
9
- */
10
- if (socket.getReadyState() !== "open")
132
+ useOwner: (use, owner) => {
133
+ if (isDisposed) {
134
+ deps.console.warn("[sync]", "useOwner called on disposed Sync instance", { owner });
11
135
  return;
12
- socket.send(message);
136
+ }
137
+ deps.console.log("[sync]", "useOwner", { use, owner });
138
+ const transportsToUse = owner.transports ?? config.transports;
139
+ if (use) {
140
+ transports.addConsumer(owner, transportsToUse);
141
+ }
142
+ else {
143
+ const result = transports.removeConsumer(owner, transportsToUse);
144
+ if (!result.ok) {
145
+ deps.console.warn("[sync]", "Failed to remove consumer", {
146
+ transportsToUse,
147
+ ownerId: owner.id,
148
+ error: result.error,
149
+ });
150
+ }
151
+ }
152
+ },
153
+ applyChanges: (changes) => {
154
+ deps.console.log("[sync]", "applyChanges", { changes });
155
+ let clockTimestamp = deps.clock.get();
156
+ const ownerMessages = new Map();
157
+ for (const change of changes) {
158
+ const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
159
+ if (!nextTimestamp.ok)
160
+ return nextTimestamp;
161
+ clockTimestamp = nextTimestamp.value;
162
+ const { ownerId = config.appOwner.id, ...dbChange } = change;
163
+ const message = { timestamp: clockTimestamp, change: dbChange };
164
+ const messages = ownerMessages.get(ownerId);
165
+ if (messages)
166
+ messages.push(message);
167
+ else
168
+ ownerMessages.set(ownerId, [message]);
169
+ }
170
+ for (const [ownerId, messages] of ownerMessages) {
171
+ const result = applyMessages({ ...deps, storage })(ownerId, messages);
172
+ if (!result.ok)
173
+ return result;
174
+ const owner = getSyncOwner(ownerId);
175
+ if (!owner?.writeKey)
176
+ continue;
177
+ const message = createProtocolMessageFromCrdtMessages(deps)({
178
+ id: owner.id,
179
+ encryptionKey: owner.encryptionKey,
180
+ writeKey: owner.writeKey,
181
+ }, messages);
182
+ const transportsToUse = owner.transports ?? config.transports;
183
+ // Send message to all transports for this owner
184
+ for (const transportConfig of transportsToUse) {
185
+ const transportKey = createTransportKey(transportConfig);
186
+ const webSocket = transports.getResource(transportKey);
187
+ if (!webSocket)
188
+ continue;
189
+ if (webSocket.isOpen()) {
190
+ deps.console.log("[sync]", "send", { transportKey, message });
191
+ webSocket.send(message);
192
+ }
193
+ }
194
+ }
195
+ return deps.clock.save(clockTimestamp);
196
+ },
197
+ [Symbol.dispose]: () => {
198
+ if (isDisposed)
199
+ return;
200
+ isDisposed = true;
201
+ transports[Symbol.dispose]();
13
202
  },
14
203
  };
15
- const socket = createWebSocket(config.syncUrl, {
16
- binaryType: "arraybuffer",
17
- onOpen: () => {
18
- config.onOpen(sync.send);
204
+ return ok(sync);
205
+ };
206
+ export const createClock = (deps) => (initialTimestamp = createInitialTimestamp(deps)) => {
207
+ let currentTimestamp = initialTimestamp;
208
+ return {
209
+ get: () => currentTimestamp,
210
+ save: (timestamp) => {
211
+ currentTimestamp = timestamp;
212
+ const timestampString = timestampToTimestampString(timestamp);
213
+ const result = deps.sqlite.exec(sql.prepared `
214
+ update evolu_config set "clock" = ${timestampString};
215
+ `);
216
+ if (!result.ok)
217
+ return result;
218
+ return ok();
19
219
  },
20
- onMessage: (data) => {
21
- if (data instanceof ArrayBuffer) {
22
- const messages = new Uint8Array(data);
23
- config.onMessage(messages, sync.send);
220
+ };
221
+ };
222
+ const createClientStorage = (deps) => (config) => {
223
+ const sqliteStorageBase = createBaseSqliteStorage(deps)({
224
+ onStorageError: config.onError,
225
+ isOwnerWithinQuota: constTrue, // Clients don't have quota limits
226
+ });
227
+ // TODO: Mutex per OwnerId
228
+ const mutex = createMutex();
229
+ const storage = {
230
+ ...sqliteStorageBase,
231
+ // Not implemented yet.
232
+ validateWriteKey: constFalse,
233
+ setWriteKey: constFalse,
234
+ writeMessages: async (ownerIdBytes, encryptedMessages) => {
235
+ const ownerId = ownerIdBytesToOwnerId(ownerIdBytes);
236
+ // Everything is sync now, but we will need async crypto in the future.
237
+ const writeResult = await mutex.withLock(async () => {
238
+ const owner = deps.getSyncOwner(ownerId);
239
+ // Owner can be removed during syncing.
240
+ // `ok(true)` means success, we just skipped the write.
241
+ if (!owner)
242
+ return ok(true);
243
+ // TODO: Add quota checking for collaborative scenarios.
244
+ // When receiving messages from other owners via relay broadcast,
245
+ // check if this owner is within quota before accepting the data.
246
+ // This prevents an owner from exceeding storage limits when receiving
247
+ // data shared by other collaborators.
248
+ const messages = [];
249
+ for (const message of encryptedMessages) {
250
+ const change = decryptAndDecodeDbChange(deps)(message, owner.encryptionKey);
251
+ if (!change.ok)
252
+ return change;
253
+ messages.push({
254
+ timestamp: message.timestamp,
255
+ change: change.value,
256
+ });
257
+ }
258
+ const transaction = deps.sqlite.transaction(() => {
259
+ let clockTimestamp = deps.clock.get();
260
+ for (const message of messages) {
261
+ const nextTimestamp = receiveTimestamp(deps)(clockTimestamp, message.timestamp);
262
+ if (!nextTimestamp.ok)
263
+ return nextTimestamp;
264
+ clockTimestamp = nextTimestamp.value;
265
+ }
266
+ if (isNonEmptyReadonlyArray(messages)) {
267
+ const applyMessagesResult = applyMessages({ ...deps, storage })(owner.id, messages);
268
+ if (!applyMessagesResult.ok)
269
+ return applyMessagesResult;
270
+ }
271
+ // // Apply local mutations atomically with approved messages
272
+ // for (const change of localMutations) {
273
+ // const result = applyLocalOnlyChange(deps)(change);
274
+ // if (!result.ok) return result;
275
+ // }
276
+ return deps.clock.save(clockTimestamp);
277
+ });
278
+ if (!transaction.ok)
279
+ return transaction;
280
+ return ok(true);
281
+ })();
282
+ if (!writeResult.ok) {
283
+ if (writeResult.error.type !== "AbortError") {
284
+ config.onError(writeResult.error);
285
+ }
286
+ return err({ type: "StorageWriteError", ownerId });
24
287
  }
288
+ config.onReceive();
289
+ return ok();
25
290
  },
26
- });
27
- return sync;
291
+ readDbChange: (ownerId, timestamp) => {
292
+ const owner = deps.getSyncOwner(ownerIdBytesToOwnerId(ownerId));
293
+ // Owner can be removed to stop syncing.
294
+ if (!owner)
295
+ return null;
296
+ const result = deps.sqlite.exec(sql `
297
+ select "table", "id", "column", "value"
298
+ from evolu_history
299
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
300
+ `);
301
+ if (!result.ok) {
302
+ config.onError(result.error);
303
+ return null;
304
+ }
305
+ const { rows } = result.value;
306
+ assert(rows.length > 0, "Rows must not be empty");
307
+ const { table, id } = rows[0];
308
+ const values = {};
309
+ for (const r of rows) {
310
+ assert(r.table === table, "All rows must have the same table");
311
+ assert(eqArrayNumber(r.id, id), "All rows must have the same Id");
312
+ values[r.column] = r.value;
313
+ }
314
+ const message = {
315
+ timestamp: timestampBytesToTimestamp(timestamp),
316
+ change: {
317
+ table: rows[0].table,
318
+ id: idBytesToId(rows[0].id),
319
+ values,
320
+ },
321
+ };
322
+ return encodeAndEncryptDbChange(deps)(message, owner.encryptionKey);
323
+ },
324
+ };
325
+ return ok(storage);
326
+ };
327
+ /** Creates a unique identifier for a transport configuration. */
328
+ const createTransportKey = (transportConfig) => {
329
+ return `${transportConfig.type}:${transportConfig.url}`;
330
+ };
331
+ export const applyLocalOnlyChange = (deps) => (change) => {
332
+ const dbChange = {
333
+ table: change.table,
334
+ id: change.id,
335
+ values: change.values,
336
+ };
337
+ const isDeletion = "isDeleted" in dbChange.values && dbChange.values.isDeleted === 1;
338
+ if (isDeletion) {
339
+ const result = deps.sqlite.exec(sql `
340
+ delete from ${sql.identifier(dbChange.table)}
341
+ where id = ${dbChange.id};
342
+ `);
343
+ if (!result.ok)
344
+ return result;
345
+ }
346
+ else {
347
+ const date = new Date(deps.time.now()).toISOString();
348
+ for (const [column, value] of objectToEntries(dbChange.values)) {
349
+ const result = deps.sqlite.exec(sql.prepared `
350
+ insert into ${sql.identifier(dbChange.table)}
351
+ ("id", ${sql.identifier(column)}, createdAt, updatedAt)
352
+ values (${dbChange.id}, ${value}, ${date}, ${date})
353
+ on conflict ("id") do update
354
+ set
355
+ ${sql.identifier(column)} = ${value},
356
+ updatedAt = ${date};
357
+ `);
358
+ if (!result.ok)
359
+ return result;
360
+ }
361
+ }
362
+ return ok();
363
+ };
364
+ const applyMessages = (deps) => (ownerId, messages) => {
365
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
366
+ const usageResult = getOwnerUsage(deps)(ownerIdBytes, timestampToTimestampBytes(firstInArray(messages).timestamp));
367
+ if (!usageResult.ok)
368
+ return usageResult;
369
+ let { firstTimestamp, lastTimestamp } = usageResult.value;
370
+ for (const message of messages) {
371
+ const result1 = applyMessageToAppTable(deps)(ownerIdBytes, message);
372
+ if (!result1.ok)
373
+ return result1;
374
+ const timestamp = timestampToTimestampBytes(message.timestamp);
375
+ let strategy;
376
+ [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(timestamp, firstTimestamp, lastTimestamp);
377
+ const result2 = applyMessageToTimestampAndHistoryTables(deps)(ownerIdBytes, message, strategy);
378
+ if (!result2.ok)
379
+ return result2;
380
+ }
381
+ /**
382
+ * TODO: Implement proper storedBytes tracking for client using encrypted
383
+ * message sizes (need to figure out how to reuse received or postpone
384
+ * client...).
385
+ */
386
+ const updateUsage = updateOwnerUsage(deps)(ownerIdBytes, 1, // Placeholder until proper tracking implemented
387
+ firstTimestamp, lastTimestamp);
388
+ if (!updateUsage.ok)
389
+ return updateUsage;
390
+ return ok();
391
+ };
392
+ const applyMessageToAppTable = (deps) => (ownerId, message) => {
393
+ const timestamp = timestampToTimestampBytes(message.timestamp);
394
+ const updatedAt = new Date(message.timestamp.millis).toISOString();
395
+ for (const [column, value] of objectToEntries(message.change.values)) {
396
+ const result = deps.sqlite.exec(sql.prepared `
397
+ with
398
+ existingTimestamp as (
399
+ select 1
400
+ from evolu_history
401
+ where
402
+ "ownerId" = ${ownerId}
403
+ and "table" = ${message.change.table}
404
+ and "id" = ${idToIdBytes(message.change.id)}
405
+ and "column" = ${column}
406
+ and "timestamp" >= ${timestamp}
407
+ limit 1
408
+ )
409
+ insert into ${sql.identifier(message.change.table)}
410
+ ("id", ${sql.identifier(column)}, updatedAt)
411
+ select ${message.change.id}, ${value}, ${updatedAt}
412
+ where not exists (select 1 from existingTimestamp)
413
+ on conflict ("id") do update
414
+ set
415
+ ${sql.identifier(column)} = ${value},
416
+ updatedAt = ${updatedAt}
417
+ where not exists (select 1 from existingTimestamp);
418
+ `);
419
+ if (!result.ok)
420
+ return result;
421
+ }
422
+ return ok();
423
+ };
424
+ export const applyMessageToTimestampAndHistoryTables = (deps) => (ownerId, message, strategy) => {
425
+ const timestamp = timestampToTimestampBytes(message.timestamp);
426
+ const id = idToIdBytes(message.change.id);
427
+ const result = deps.storage.insertTimestamp(ownerId, timestamp, strategy);
428
+ if (!result.ok)
429
+ return result;
430
+ for (const [column, value] of Object.entries(message.change.values)) {
431
+ const result = deps.sqlite.exec(sql.prepared `
432
+ insert into evolu_history
433
+ ("ownerId", "table", "id", "column", "value", "timestamp")
434
+ values
435
+ (
436
+ ${ownerId},
437
+ ${message.change.table},
438
+ ${id},
439
+ ${column},
440
+ ${value},
441
+ ${timestamp}
442
+ )
443
+ on conflict do nothing;
444
+ `);
445
+ if (!result.ok)
446
+ return result;
447
+ }
448
+ return ok();
28
449
  };
29
450
  export const initialSyncState = { type: "SyncStateInitial" };
@@ -1,8 +1,9 @@
1
- import { NanoIdLibDep } from "../NanoId.js";
1
+ import { Brand } from "../Brand.js";
2
+ import { RandomBytesDep } from "../Crypto.js";
2
3
  import { Order } from "../Order.js";
3
4
  import { Result } from "../Result.js";
4
5
  import { TimeDep } from "../Time.js";
5
- import { Brand } from "../Types.js";
6
+ import { InferType } from "../Type.js";
6
7
  export interface TimestampConfig {
7
8
  /**
8
9
  * Maximum physical clock drift allowed in ms.
@@ -14,7 +15,7 @@ export interface TimestampConfig {
14
15
  export interface TimestampConfigDep {
15
16
  readonly timestampConfig: TimestampConfig;
16
17
  }
17
- export type TimestampError = TimestampDriftError | TimestampCounterOverflowError | TimestampDuplicateNodeError | TimestampTimeOutOfRangeError;
18
+ export type TimestampError = TimestampDriftError | TimestampCounterOverflowError | TimestampTimeOutOfRangeError;
18
19
  export interface TimestampDriftError {
19
20
  readonly type: "TimestampDriftError";
20
21
  readonly next: Millis;
@@ -23,10 +24,6 @@ export interface TimestampDriftError {
23
24
  export interface TimestampCounterOverflowError {
24
25
  readonly type: "TimestampCounterOverflowError";
25
26
  }
26
- export interface TimestampDuplicateNodeError {
27
- readonly type: "TimestampDuplicateNodeError";
28
- readonly nodeId: NodeId;
29
- }
30
27
  export interface TimestampTimeOutOfRangeError {
31
28
  readonly type: "TimestampTimeOutOfRangeError";
32
29
  }
@@ -42,11 +39,11 @@ export interface TimestampTimeOutOfRangeError {
42
39
  *
43
40
  * `new Date(281474976710654).toString()` = Tue Aug 02 10889 07:31:49
44
41
  */
45
- export declare const Millis: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").NumberError | import("../Type.js").IntError>, `LessThanOrEqualTo${number}`, import("../Type.js").LessThanOrEqualToError<number>, import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError>, "Millis", import("../Type.js").BrandWithoutRefineError<"Millis", import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").LessThanOrEqualToError<number>>, never>;
42
+ export declare const Millis: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").IntError | import("../Type.js").NumberError>, `LessThanOrEqualTo${number}`, import("../Type.js").LessThanOrEqualToError<number>, import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError>, "Millis", import("../Type.js").BrandWithoutRefineError<"Millis", import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError | import("../Type.js").LessThanOrEqualToError<number>>, never>;
46
43
  export type Millis = typeof Millis.Type;
47
44
  export declare const minMillis: Millis;
48
45
  export declare const maxMillis: Millis;
49
- export declare const Counter: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").NumberError | import("../Type.js").IntError>, "LessThanOrEqualTo65535", import("../Type.js").LessThanOrEqualToError<65535>, import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError>, "Counter", import("../Type.js").BrandWithoutRefineError<"Counter", import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").LessThanOrEqualToError<65535>>, never>;
46
+ export declare const Counter: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").IntError | import("../Type.js").NumberError>, "LessThanOrEqualTo65535", import("../Type.js").LessThanOrEqualToError<65535>, import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError>, "Counter", import("../Type.js").BrandWithoutRefineError<"Counter", import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError | import("../Type.js").LessThanOrEqualToError<65535>>, never>;
50
47
  export type Counter = typeof Counter.Type;
51
48
  export declare const minCounter: Counter;
52
49
  export declare const maxCounter: Counter;
@@ -63,14 +60,17 @@ export declare const maxCounter: Counter;
63
60
  *
64
61
  * https://lemire.me/blog/2019/12/12/are-64-bit-random-identifiers-free-from-collision
65
62
  *
66
- * What will happen if a different device generates the same NodeId?
63
+ * What happens if different devices generate the same NodeId?
67
64
  *
68
- * If the device belongs to a different owner, nothing will happen because
69
- * different owner have different owner IDs. Timestamps are partitioned by
70
- * OwnerId.
65
+ * If devices with the same NodeId use different owners, no issues occur.
71
66
  *
72
- * If the device belongs to the same owner, the other device will return
73
- * {@link TimestampDuplicateNodeError}.
67
+ * If devices with the same NodeId use the same owner, problems only arise when
68
+ * they generate CRDT messages with identical timestamps (same millis, counter,
69
+ * and NodeId). In this case, the protocol sync algorithm treats them as the
70
+ * same message: the first will be synced with the relay, while the affected
71
+ * message will not be delivered. The affected devices will see different data
72
+ * yet they will think they are synced. This is extremely rare and can be
73
+ * resolved by resetting one device to generate a new NodeId.
74
74
  */
75
75
  export declare const NodeId: import("../Type.js").BrandType<import("../Type.js").Type<"String", string, string, import("../Type.js").StringError, string, import("../Type.js").StringError>, "NodeId", import("../Type.js").RegexError<"NodeId">, import("../Type.js").StringError>;
76
76
  export type NodeId = typeof NodeId.Type;
@@ -79,28 +79,86 @@ export declare const maxNodeId: NodeId;
79
79
  /**
80
80
  * Hybrid Logical Clock timestamp.
81
81
  *
82
+ * Timestamps serve as globally unique, causally ordered identifiers for CRDT
83
+ * messages in Evolu's sync protocol.
84
+ *
85
+ * ### Why Hybrid Logical Clocks
86
+ *
87
+ * Evolu uses Hybrid Logical Clocks (HLC), which combine physical time (millis)
88
+ * with a logical counter. This hybrid approach preserves causality like logical
89
+ * clocks while staying close to physical time for better human
90
+ * interpretability.
91
+ *
92
+ * The counter component ensures causality is maintained even when physical
93
+ * clocks are imperfect. When clocks drift or operations occur concurrently, the
94
+ * counter increments to establish a total order. This means Evolu achieves
95
+ * well-defined, eventually-consistent behavior regardless of physical clock
96
+ * accuracy.
97
+ *
98
+ * Vector clocks can accurately track causality and detect concurrent
99
+ * operations, but they require unbounded space in peer-to-peer systems and
100
+ * crucially, still don't solve our fundamental problem: when they detect
101
+ * operations as concurrent, we still need a deterministic way to choose a
102
+ * winner. Additionally, any deterministic conflict resolution can be gamed by
103
+ * malicious actors.
104
+ *
105
+ * HLC timestamps work well in practice because modern device clocks accurately
106
+ * reflect the order of sequential edits in the common case. Evolu's `maxDrift`
107
+ * configuration protects against buggy clocks and prevents problematic
108
+ * future-dated entries from propagating through the network.
109
+ *
110
+ * ### References
111
+ *
82
112
  * - https://muratbuffalo.blogspot.com/2014/07/hybrid-logical-clocks.html
83
113
  * - https://sergeiturukin.com/2017/06/26/hybrid-logical-clocks.html
84
114
  * - https://jaredforsyth.com/posts/hybrid-logical-clocks/
115
+ * - https://willowprotocol.org/more/timestamps_really/index.html
116
+ *
117
+ * ### Privacy Considerations
118
+ *
119
+ * Timestamps are metadata visible to relays and collaborators. While it can be
120
+ * considered a privacy leak, let us explain why it's necessary, and how to
121
+ * avoid it if maximum privacy is required.
122
+ *
123
+ * With real-time communication, participants always see activity (receiving
124
+ * bytes). We cannot trust anyone not to store that information, so explicitly
125
+ * exposing timestamps doesn't add additional risk.
126
+ *
127
+ * If we really want not to leak user activity, we can implement a local write
128
+ * queue:
129
+ *
130
+ * 1. Write changes immediately to a local-only table
131
+ * 2. Periodically/randomly flush messages to sync tables
132
+ * 3. This decouples user activity from sync timing
133
+ *
134
+ * Tradeoff: It breaks real-time collaboration.
85
135
  */
86
136
  export declare const Timestamp: import("../Type.js").ObjectType<{
87
- millis: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").NumberError | import("../Type.js").IntError>, `LessThanOrEqualTo${number}`, import("../Type.js").LessThanOrEqualToError<number>, import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError>, "Millis", import("../Type.js").BrandWithoutRefineError<"Millis", import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").LessThanOrEqualToError<number>>, never>;
88
- counter: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").NumberError | import("../Type.js").IntError>, "LessThanOrEqualTo65535", import("../Type.js").LessThanOrEqualToError<65535>, import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError>, "Counter", import("../Type.js").BrandWithoutRefineError<"Counter", import("../Type.js").NumberError | import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").LessThanOrEqualToError<65535>>, never>;
137
+ millis: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").IntError | import("../Type.js").NumberError>, `LessThanOrEqualTo${number}`, import("../Type.js").LessThanOrEqualToError<number>, import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError>, "Millis", import("../Type.js").BrandWithoutRefineError<"Millis", import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError | import("../Type.js").LessThanOrEqualToError<number>>, never>;
138
+ counter: import("../Type.js").BrandType<import("../Type.js").BrandType<import("../Type.js").Type<"Brand", number & Brand<"Int"> & Brand<"NonNegative">, number, import("../Type.js").NonNegativeError, number & Brand<"Int">, import("../Type.js").IntError | import("../Type.js").NumberError>, "LessThanOrEqualTo65535", import("../Type.js").LessThanOrEqualToError<65535>, import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError>, "Counter", import("../Type.js").BrandWithoutRefineError<"Counter", import("../Type.js").NonNegativeError | import("../Type.js").IntError | import("../Type.js").NumberError | import("../Type.js").LessThanOrEqualToError<65535>>, never>;
89
139
  nodeId: import("../Type.js").BrandType<import("../Type.js").Type<"String", string, string, import("../Type.js").StringError, string, import("../Type.js").StringError>, "NodeId", import("../Type.js").RegexError<"NodeId">, import("../Type.js").StringError>;
90
140
  }>;
91
- export type Timestamp = typeof Timestamp.Type;
141
+ export interface Timestamp extends InferType<typeof Timestamp> {
142
+ }
143
+ /** Equality function for comparing {@link Timestamp}. */
144
+ export declare const eqTimestamp: import("../Eq.js").Eq<{
145
+ readonly millis: number & Brand<"Int"> & Brand<"NonNegative"> & Brand<`LessThanOrEqualTo${number}`> & Brand<"Millis">;
146
+ readonly counter: number & Brand<"Int"> & Brand<"NonNegative"> & Brand<"LessThanOrEqualTo65535"> & Brand<"Counter">;
147
+ readonly nodeId: string & Brand<"NodeId">;
148
+ }>;
92
149
  export declare const createTimestamp: ({ millis, counter, nodeId, }?: Partial<Timestamp>) => Timestamp;
93
- export declare const createInitialTimestamp: (deps: NanoIdLibDep) => Timestamp;
94
- /** TimestampString is a sortable string version of {@link Timestamp}. */
150
+ export declare const createInitialTimestamp: (deps: RandomBytesDep) => Timestamp;
151
+ /** Sortable string representation of {@link Timestamp}. */
95
152
  export type TimestampString = string & Brand<"TimestampString">;
96
153
  export declare const timestampToTimestampString: (t: Timestamp) => TimestampString;
97
154
  export declare const timestampStringToTimestamp: (timestampString: TimestampString) => Timestamp;
98
155
  export declare const sendTimestamp: (deps: TimeDep & TimestampConfigDep) => (timestamp: Timestamp) => Result<Timestamp, TimestampDriftError | TimestampCounterOverflowError | TimestampTimeOutOfRangeError>;
99
- export declare const receiveTimestamp: (deps: TimeDep & TimestampConfigDep) => (local: Timestamp, remote: Timestamp) => Result<Timestamp, TimestampDriftError | TimestampCounterOverflowError | TimestampDuplicateNodeError | TimestampTimeOutOfRangeError>;
100
- /** BinaryTimestamp is a binary and sortable version of {@link Timestamp} for DB. */
101
- export type BinaryTimestamp = Uint8Array & Brand<"BinaryTimestamp">;
102
- export declare const binaryTimestampLength: number & Brand<"Int"> & Brand<"NonNegative">;
103
- export declare const timestampToBinaryTimestamp: (timestamp: Timestamp) => BinaryTimestamp;
104
- export declare const binaryTimestampToTimestamp: (timestamp: BinaryTimestamp) => Timestamp;
105
- export declare const orderBinaryTimestamp: Order<BinaryTimestamp>;
156
+ export declare const receiveTimestamp: (deps: TimeDep & TimestampConfigDep) => (local: Timestamp, remote: Timestamp) => Result<Timestamp, TimestampDriftError | TimestampCounterOverflowError | TimestampTimeOutOfRangeError>;
157
+ /** Sortable bytes representation of {@link Timestamp}. */
158
+ export declare const TimestampBytes: import("../Type.js").BrandType<import("../Type.js").Type<"Uint8Array", Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>, import("../Type.js").Uint8ArrayError, Uint8Array<ArrayBufferLike>, import("../Type.js").Uint8ArrayError>, "TimestampBytes", import("../Type.js").BrandWithoutRefineError<"TimestampBytes", import("../Type.js").Uint8ArrayError>, never>;
159
+ export type TimestampBytes = typeof TimestampBytes.Type;
160
+ export declare const timestampBytesLength: number & Brand<"Int"> & Brand<"NonNegative">;
161
+ export declare const timestampToTimestampBytes: (timestamp: Timestamp) => TimestampBytes;
162
+ export declare const timestampBytesToTimestamp: (timestamp: TimestampBytes) => Timestamp;
163
+ export declare const orderTimestampBytes: Order<TimestampBytes>;
106
164
  //# sourceMappingURL=Timestamp.d.ts.map