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