@evolu/common 7.4.0 → 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 +1064 -630
  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 -823
  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 +209 -282
  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 -529
  246. package/src/Instances.ts +0 -90
  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 -960
@@ -1,483 +1,408 @@
1
+ /**
2
+ * Platform-agnostic Evolu DbWorker.
3
+ *
4
+ * @module
5
+ */
6
+
1
7
  import {
8
+ appendToArray,
2
9
  firstInArray,
3
- isNonEmptyArray,
4
- NonEmptyReadonlyArray,
10
+ type NonEmptyArray,
11
+ type NonEmptyReadonlyArray,
5
12
  } from "../Array.js";
6
- import { assertNonEmptyReadonlyArray } from "../Assert.js";
7
- import { CallbackId } from "../Callbacks.js";
8
- import { ConsoleConfig, ConsoleDep } from "../Console.js";
13
+ import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
14
+ import type { ConsoleLevel } from "../Console.js";
9
15
  import {
10
- createSymmetricCrypto,
11
16
  EncryptionKey,
12
- RandomBytesDep,
13
- SymmetricCryptoDecryptError,
17
+ type DecryptWithXChaCha20Poly1305Error,
18
+ type RandomBytesDep,
14
19
  } from "../Crypto.js";
15
- import { TransferableError } from "../Error.js";
16
- import { RandomDep } from "../Random.js";
17
- import { ok, Result } from "../Result.js";
20
+ import { exhaustiveCheck, lazyFalse, lazyVoid } from "../Function.js";
21
+ import { createRecord, getProperty, objectToEntries } from "../Object.js";
22
+ import { ok, type Result } from "../Result.js";
23
+ import type {
24
+ CreateSqliteDriverDep,
25
+ SqliteDep,
26
+ SqliteRow,
27
+ SqliteSchema,
28
+ } from "../Sqlite.js";
18
29
  import {
30
+ booleanToSqliteBoolean,
19
31
  createSqlite,
20
- CreateSqliteDriverDep,
21
32
  sql,
22
- SqliteDep,
23
- SqliteError,
33
+ SqliteBoolean,
34
+ sqliteBooleanToBoolean,
35
+ sqliteQueryStringToSqliteQuery,
36
+ SqliteValue,
24
37
  } from "../Sqlite.js";
25
- import { TimeDep } from "../Time.js";
26
- import { Id, Mnemonic, SimpleName } from "../Type.js";
27
- import { CreateWebSocketDep } from "../WebSocket.js";
38
+ import { callback, type LeaderLockDep, type Task } from "../Task.js";
39
+ import { Millis, millisToDateIso, type TimeDep } from "../Time.js";
40
+ import type { Name } from "../Type.js";
28
41
  import {
29
- createInitializedWorkerWithHandlers,
30
- MessageHandlers,
42
+ Id,
43
+ IdBytes,
44
+ idBytesToId,
45
+ idToIdBytes,
46
+ onePositiveInt,
47
+ } from "../Type.js";
48
+ import type { ExtractType } from "../Types.js";
49
+ import type {
50
+ NativeMessagePort,
31
51
  Worker,
52
+ WorkerDeps,
53
+ WorkerSelf,
32
54
  } from "../Worker.js";
55
+ import type { OwnerId, OwnerIdBytes } from "./Owner.js";
56
+ import { ownerIdBytesToOwnerId, ownerIdToOwnerIdBytes } from "./Owner.js";
33
57
  import {
34
- AppOwner,
35
- AppOwnerDep,
36
- createAppOwner,
37
- createOwnerSecret,
38
- createOwnerWebSocketTransport,
39
- mnemonicToOwnerSecret,
40
- OwnerEncryptionKey,
41
- OwnerId,
42
- OwnerTransport,
43
- OwnerWriteKey,
44
- } from "./Owner.js";
45
- import { ProtocolError, protocolVersion } from "./Protocol.js";
58
+ applyProtocolMessageAsClient,
59
+ createProtocolMessageForSync,
60
+ decryptAndDecodeDbChange,
61
+ encodeAndEncryptDbChange,
62
+ protocolVersion,
63
+ SubscriptionFlags,
64
+ type ProtocolInvalidDataError,
65
+ type ProtocolMessage,
66
+ type ProtocolTimestampMismatchError,
67
+ } from "./Protocol.js";
68
+ import type { Query, RowsByQueryMap } from "./Query.js";
69
+ import type { MutationChange, SqliteSchemaDep } from "./Schema.js";
46
70
  import {
47
- createGetQueryRowsCache,
48
- GetQueryRowsCacheDep,
49
- loadQueries,
50
- Query,
51
- QueryPatches,
52
- } from "./Query.js";
53
- import {
54
- DbSchema,
55
- ensureDbSchema,
56
- getDbSchema,
57
- MutationChange,
71
+ ensureSqliteSchema,
72
+ getEvoluSqliteSchema,
73
+ systemColumns,
58
74
  } from "./Schema.js";
59
- import { createBaseSqliteStorageTables } from "./Storage.js";
60
- import {
61
- applyLocalOnlyChange,
62
- Clock,
63
- createClock,
64
- createSync,
65
- SyncDep,
66
- SyncOwner,
67
- tryApplyQuarantinedMessages,
68
- } from "./Sync.js";
75
+ import type { DbWorkerInput, DbWorkerOutput } from "./Shared.js";
69
76
  import {
77
+ createBaseSqliteStorage,
78
+ createBaseSqliteStorageTables,
79
+ DbChange,
80
+ getOwnerUsage,
81
+ getTimestampInsertStrategy,
82
+ updateOwnerUsage,
83
+ type BaseSqliteStorage,
84
+ type BaseSqliteStorageDep,
85
+ type CrdtMessage,
86
+ type Storage,
87
+ } from "./Storage.js";
88
+ import type {
70
89
  Timestamp,
90
+ TimestampCounterOverflowError,
91
+ TimestampDriftError,
92
+ TimestampTimeOutOfRangeError,
93
+ } from "./Timestamp.js";
94
+ import {
95
+ createInitialTimestamp,
96
+ defaultTimestampMaxDrift,
97
+ receiveTimestamp,
98
+ sendTimestamp,
71
99
  TimestampBytes,
72
100
  timestampBytesToTimestamp,
73
- TimestampConfig,
74
- TimestampError,
75
101
  timestampToTimestampBytes,
102
+ type TimestampConfigDep,
76
103
  } from "./Timestamp.js";
77
104
 
78
- export interface DbConfig extends ConsoleConfig, TimestampConfig {
79
- /**
80
- * The name of the Evolu instance. Evolu is multitenant - it can run multiple
81
- * instances concurrently. Each instance must have a unique name.
82
- *
83
- * The instance name is used as the SQLite database filename for persistent
84
- * storage, ensuring that database files are separated and invisible to each
85
- * other.
86
- *
87
- * The default value is: `Evolu`.
88
- *
89
- * ### Example
90
- *
91
- * ```ts
92
- * // name: SimpleName.orThrow("MyApp")
93
- * ```
94
- */
95
- readonly name: SimpleName;
96
-
97
- /**
98
- * Transport configuration for data sync and backup. Supports single transport
99
- * or multiple transports simultaneously for redundancy.
100
- *
101
- * **Redundancy:** The ideal setup uses at least two completely independent
102
- * relays - for example, a home relay and a geographically separate relay.
103
- * Data is sent to both relays simultaneously, providing true redundancy
104
- * similar to using two independent clouds. This eliminates vendor lock-in and
105
- * ensures your app continues working regardless of circumstances - whether
106
- * home relay hardware is stolen or a remote relay provider shuts down.
107
- *
108
- * Currently supports:
109
- *
110
- * - WebSocket: Real-time bidirectional communication with relay servers
111
- *
112
- * Empty transports create local-only instances. Transports can be dynamically
113
- * added and removed for any owner (including {@link AppOwner}) via
114
- * {@link Evolu#useOwner}.
115
- *
116
- * Use {@link createOwnerWebSocketTransport} to create WebSocket transport
117
- * configurations with proper URL formatting and {@link OwnerId} inclusion. The
118
- * {@link OwnerId} in the URL enables relay authentication, allowing relay
119
- * servers to control access (e.g., for paid tiers or private instances).
120
- *
121
- * The default value is:
122
- *
123
- * `{ type: "WebSocket", url: "wss://free.evoluhq.com" }`.
124
- *
125
- * ### Example
126
- *
127
- * ```ts
128
- * // Single WebSocket relay
129
- * transports: [{ type: "WebSocket", url: "wss://relay1.example.com" }];
130
- *
131
- * // Multiple WebSocket relays for redundancy
132
- * transports: [
133
- * { type: "WebSocket", url: "wss://relay1.example.com" },
134
- * { type: "WebSocket", url: "wss://relay2.example.com" },
135
- * { type: "WebSocket", url: "wss://relay3.example.com" },
136
- * ];
137
- *
138
- * // Local-only instance (no sync) - useful for device settings or when relay
139
- * // URL will be provided later (e.g., after authentication), allowing users
140
- * // to work offline before the app connects
141
- * transports: [];
142
- *
143
- * // Using createOwnerWebSocketTransport helper for relay authentication
144
- * transports: [
145
- * createOwnerWebSocketTransport({
146
- * url: "ws://localhost:4000",
147
- * ownerId,
148
- * }),
149
- * ];
150
- * ```
151
- */
152
- readonly transports: ReadonlyArray<OwnerTransport>;
153
-
154
- /**
155
- * External AppOwner to use when creating Evolu instance. Use this when you
156
- * want to manage AppOwner creation and persistence externally (e.g., with
157
- * your own authentication system). If omitted, Evolu will automatically
158
- * create and persist an AppOwner locally.
159
- *
160
- * For device-specific settings and account management state, we can use a
161
- * separate local-only Evolu instance via `transports: []`.
162
- *
163
- * ### Example
164
- *
165
- * ```ts
166
- * const ConfigId = id("Config");
167
- * type ConfigId = typeof ConfigId.Type;
168
- *
169
- * const DeviceSchema = {
170
- * config: {
171
- * id: ConfigId,
172
- * key: NonEmptyString50,
173
- * value: NonEmptyString50,
174
- * },
175
- * };
176
- *
177
- * // Local-only instance for device settings (no sync)
178
- * const deviceEvolu = createEvolu(evoluReactWebDeps)(DeviceSchema, {
179
- * name: SimpleName.orThrow("MyApp-Device"),
180
- * transports: [], // No sync - stays local to device
181
- * });
182
- *
183
- * // Main synced instance for user data
184
- * const evolu = createEvolu(evoluReactWebDeps)(MainSchema, {
185
- * name: SimpleName.orThrow("MyApp"),
186
- * // Default transports for sync
187
- * });
188
- * ```
189
- */
190
- readonly externalAppOwner?: AppOwner;
191
-
192
- /**
193
- * Use in-memory SQLite database instead of persistent storage. Useful for
194
- * testing or temporary data that doesn't need persistence.
195
- *
196
- * In-memory databases exist only in RAM and are completely destroyed when the
197
- * process ends, making them forensically safe for sensitive data.
198
- *
199
- * The default value is: `false`.
200
- */
201
- readonly inMemory?: boolean;
202
-
203
- /**
204
- * Encryption key for the SQLite database.
205
- *
206
- * Note: If an unencrypted SQLite database already exists and you provide an
207
- * encryptionKey, SQLite will throw an error.
208
- *
209
- * @experimental
210
- */
211
- readonly encryptionKey?: EncryptionKey;
212
- }
213
-
214
- export const defaultDbConfig: DbConfig = {
215
- name: SimpleName.orThrow("Evolu"),
216
- transports: [{ type: "WebSocket", url: "wss://free.evoluhq.com" }],
217
- maxDrift: 5 * 60 * 1000,
218
- enableLogging: false,
219
- };
105
+ export type DbWorker = Worker<DbWorkerInit>;
220
106
 
221
- export type DbWorker = Worker<DbWorkerInput, DbWorkerOutput>;
107
+ export interface DbWorkerInit {
108
+ readonly type: "Init";
109
+ readonly name: Name;
110
+ readonly consoleLevel: ConsoleLevel;
111
+ readonly sqliteSchema: SqliteSchema;
112
+ readonly encryptionKey: EncryptionKey;
113
+ readonly memoryOnly: boolean;
114
+ readonly port: NativeMessagePort<DbWorkerOutput, DbWorkerInput>;
115
+ }
222
116
 
223
- export type CreateDbWorker = (name: SimpleName) => DbWorker;
117
+ export type CreateDbWorker = () => DbWorker;
224
118
 
225
119
  export interface CreateDbWorkerDep {
226
120
  readonly createDbWorker: CreateDbWorker;
227
121
  }
228
122
 
229
- export type DbWorkerInput =
230
- | {
231
- readonly type: "init";
232
- readonly config: DbConfig;
233
- readonly dbSchema: DbSchema;
234
- }
235
- | {
236
- readonly type: "getAppOwner";
237
- }
238
- | {
239
- readonly type: "mutate";
240
- readonly tabId: Id;
241
- readonly changes: NonEmptyReadonlyArray<MutationChange>;
242
- readonly onCompleteIds: ReadonlyArray<CallbackId>;
243
- readonly subscribedQueries: ReadonlyArray<Query>;
244
- }
245
- | {
246
- readonly type: "query";
247
- readonly tabId: Id;
248
- readonly queries: NonEmptyReadonlyArray<Query>;
249
- }
250
- | {
251
- readonly type: "reset";
252
- readonly onCompleteId: CallbackId;
253
- readonly reload: boolean;
254
- readonly restore?: {
255
- readonly dbSchema: DbSchema;
256
- readonly mnemonic: Mnemonic;
257
- };
258
- }
259
- | {
260
- readonly type: "ensureDbSchema";
261
- readonly dbSchema: DbSchema;
262
- }
263
- | {
264
- readonly type: "export";
265
- readonly onCompleteId: CallbackId;
266
- }
267
- | {
268
- readonly type: "useOwner";
269
- readonly use: boolean;
270
- readonly owner: SyncOwner;
271
- };
272
-
273
- export type DbWorkerOutput =
274
- | {
275
- readonly type: "onError";
276
- readonly error:
277
- | ProtocolError
278
- | SqliteError
279
- | SymmetricCryptoDecryptError
280
- | TimestampError
281
- | TransferableError;
282
- }
283
- | {
284
- readonly type: "onGetAppOwner";
285
- readonly appOwner: AppOwner;
286
- }
287
- | {
288
- readonly type: "onQueryPatches";
289
- readonly tabId: Id;
290
- readonly queryPatches: ReadonlyArray<QueryPatches>;
291
- readonly onCompleteIds: ReadonlyArray<CallbackId>;
292
- }
293
- | {
294
- readonly type: "refreshQueries";
295
- readonly tabId?: Id;
296
- }
297
- | {
298
- readonly type: "onReset";
299
- readonly onCompleteId: CallbackId;
300
- readonly reload: boolean;
301
- }
302
- | {
303
- readonly type: "onExport";
304
- readonly onCompleteId: CallbackId;
305
- readonly file: Uint8Array;
306
- };
307
-
308
- export type DbWorkerPlatformDeps = ConsoleDep &
123
+ export type DbWorkerDeps = WorkerDeps &
124
+ LeaderLockDep &
309
125
  CreateSqliteDriverDep &
310
- CreateWebSocketDep &
311
- RandomBytesDep &
312
- RandomDep &
313
- TimeDep;
314
-
315
- type DbWorkerDeps = Omit<
316
- DbWorkerPlatformDeps,
317
- keyof CreateSqliteDriverDep | keyof CreateWebSocketDep
318
- > &
319
- AppOwnerDep &
320
- GetQueryRowsCacheDep &
321
- PostMessageDep &
322
- SqliteDep &
323
- SyncDep;
324
-
325
- export interface PostMessageDep {
326
- readonly postMessage: (message: DbWorkerOutput) => void;
327
- }
126
+ RandomBytesDep;
127
+
128
+ export const startDbWorker =
129
+ (self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
130
+ async (_run) => {
131
+ await using stack = new AsyncDisposableStack();
132
+ const run = stack.use(_run.create());
133
+ const { deps } = run;
134
+
135
+ let initialized = false;
136
+
137
+ const initMessage = await run.orThrow(
138
+ callback<DbWorkerInit>(({ ok }) => {
139
+ self.onMessage = (message) => {
140
+ assert(!initialized, "DbWorker must be initialized only once");
141
+ initialized = true;
142
+ ok(message);
143
+ };
144
+ }),
145
+ );
328
146
 
329
- export const createDbWorkerForPlatform = (
330
- platformDeps: DbWorkerPlatformDeps,
331
- ): DbWorker =>
332
- createInitializedWorkerWithHandlers<
333
- DbWorkerInput,
334
- DbWorkerOutput,
335
- DbWorkerDeps
336
- >({
337
- init: async (initMessage, postMessage) => {
338
- platformDeps.console.enabled = initMessage.config.enableLogging ?? false;
339
-
340
- const deps = await createDbWorkerDeps(
341
- platformDeps,
342
- initMessage,
343
- postMessage,
344
- );
147
+ const console = deps.console.child(initMessage.name).child("DbWorker");
148
+ console.setLevel(initMessage.consoleLevel);
149
+ console.info("start DbWorker");
345
150
 
346
- if (!deps.ok) {
347
- postMessage({ type: "onError", error: deps.error });
348
- return null;
349
- }
151
+ const port = stack.use(
152
+ deps.createMessagePort<DbWorkerOutput, DbWorkerInput>(initMessage.port),
153
+ );
350
154
 
351
- return deps.value;
352
- },
353
- handlers,
354
- });
155
+ stack.defer(
156
+ deps.consoleStoreOutputEntry.subscribe(() => {
157
+ const entry = deps.consoleStoreOutputEntry.get();
158
+ if (entry) port.postMessage({ type: "OnConsoleEntry", entry });
159
+ }),
160
+ );
355
161
 
356
- const createDbWorkerDeps = async (
357
- platformDeps: DbWorkerPlatformDeps,
358
- initMessage: Extract<DbWorkerInput, { type: "init" }>,
359
- postMessage: (msg: DbWorkerOutput) => void,
360
- ) => {
361
- const sqlite = await createSqlite(platformDeps)(initMessage.config.name, {
362
- memory: initMessage.config.inMemory ?? false,
363
- encryptionKey: initMessage.config.encryptionKey ?? undefined,
364
- });
365
- if (!sqlite.ok) return sqlite;
162
+ stack.use(await run.orThrow(deps.leaderLock.lock(initMessage.name)));
163
+ if (stack.disposed) return ok();
366
164
 
367
- const deps = { ...platformDeps, sqlite: sqlite.value };
165
+ port.postMessage({ type: "LeaderAcquired", name: initMessage.name });
368
166
 
369
- return deps.sqlite.transaction(() => {
370
- const dbSchema = getDbSchema(deps)();
371
- if (!dbSchema.ok) return dbSchema;
167
+ const sqlite = stack.use(
168
+ await run.orThrow(
169
+ createSqlite(
170
+ initMessage.name,
171
+ initMessage.memoryOnly
172
+ ? { mode: "memory" }
173
+ : { mode: "encrypted", encryptionKey: initMessage.encryptionKey },
174
+ ),
175
+ ),
176
+ );
177
+ console.debug("SQLite created");
372
178
 
373
- const dbIsInitialized = "evolu_version" in dbSchema.value.tables;
179
+ const baseSqliteStorage = createBaseSqliteStorage({ sqlite, ...run.deps });
374
180
 
375
- let appOwner: AppOwner;
376
- let clock: Clock;
181
+ const dbDeps = {
182
+ ...run.deps,
183
+ sqlite,
184
+ sqliteSchema: initMessage.sqliteSchema,
185
+ baseSqliteStorage,
186
+ timestampConfig: { maxDrift: defaultTimestampMaxDrift },
187
+ };
377
188
 
378
- if (dbIsInitialized) {
379
- const currentVersion = deps.sqlite.exec<{
380
- protocolVersion: number;
381
- }>(sql`select protocolVersion from evolu_version limit 1;`);
382
- if (!currentVersion.ok) return currentVersion;
383
-
384
- const configResult = deps.sqlite.exec<{
385
- clock: TimestampBytes;
386
- appOwnerId: OwnerId;
387
- appOwnerEncryptionKey: OwnerEncryptionKey;
388
- appOwnerWriteKey: OwnerWriteKey;
389
- appOwnerMnemonic: Mnemonic | null;
390
- }>(sql`
391
- select
392
- clock,
393
- appOwnerId,
394
- appOwnerEncryptionKey,
395
- appOwnerWriteKey,
396
- appOwnerMnemonic
397
- from evolu_config
398
- limit 1;
399
- `);
400
- if (!configResult.ok) return configResult;
189
+ const currentSchema = getEvoluSqliteSchema(dbDeps)();
190
+ const dbIsInitialized = "evolu_version" in currentSchema.tables;
191
+ const clock = createClock(dbDeps)(dbIsInitialized);
401
192
 
402
- assertNonEmptyReadonlyArray(configResult.value.rows);
403
- const config = firstInArray(configResult.value.rows);
193
+ sqlite.transaction(() => {
194
+ if (!dbIsInitialized) initializeDb(dbDeps)(clock.get());
195
+ ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema, currentSchema);
196
+ tryApplyQuarantinedMessages(dbDeps);
197
+ });
404
198
 
405
- appOwner = {
406
- type: "AppOwner",
407
- id: config.appOwnerId,
408
- encryptionKey: config.appOwnerEncryptionKey,
409
- writeKey: config.appOwnerWriteKey,
410
- mnemonic: config.appOwnerMnemonic,
199
+ const storage = createClientStorage({ ...dbDeps, clock })({
200
+ onError: (error) => {
201
+ port.postMessage({ type: "OnError", error });
202
+ },
203
+ });
204
+
205
+ // TODO: Call on dispose message.
206
+ const _moved = stack.move();
207
+ const runWithStorage = run.addDeps({ storage });
208
+
209
+ /**
210
+ * SharedWorker repeats sends until it gets a response, so handling here
211
+ * must be idempotent and ignore already processed IDs.
212
+ *
213
+ * TODO: Bound memory growth by evicting old IDs.
214
+ */
215
+ const processedRequestIds = new Set<Id>();
216
+
217
+ port.onMessage = ({ callbackId, request }) => {
218
+ if (processedRequestIds.has(callbackId)) return;
219
+ processedRequestIds.add(callbackId);
220
+
221
+ const postQueuedResponse = (
222
+ response: ExtractType<DbWorkerOutput, "OnQueuedResponse">["response"],
223
+ ): void => {
224
+ port.postMessage(
225
+ { type: "OnQueuedResponse", callbackId, response },
226
+ response.type === "ForEvolu" && response.message.type === "Export"
227
+ ? [response.message.file.buffer]
228
+ : undefined,
229
+ );
411
230
  };
412
231
 
413
- clock = createClock(deps)(timestampBytesToTimestamp(config.clock));
414
- } else {
415
- appOwner =
416
- initMessage.config.externalAppOwner ??
417
- createAppOwner(createOwnerSecret(platformDeps));
232
+ switch (request.type) {
233
+ case "ForEvolu": {
234
+ switch (request.message.type) {
235
+ case "Mutate": {
236
+ const result = handleMutation({ ...dbDeps, clock })(
237
+ request.message,
238
+ );
239
+ if (!result.ok) {
240
+ port.postMessage({ type: "OnError", error: result.error });
241
+ } else {
242
+ postQueuedResponse({
243
+ type: "ForEvolu",
244
+ evoluPortId: request.evoluPortId,
245
+ message: result.value,
246
+ });
247
+ }
248
+ break;
249
+ }
250
+
251
+ case "Query":
252
+ postQueuedResponse({
253
+ type: "ForEvolu",
254
+ evoluPortId: request.evoluPortId,
255
+ message: {
256
+ type: "Query",
257
+ rowsByQuery: loadQueries(dbDeps)(request.message.queries),
258
+ },
259
+ });
260
+ break;
261
+
262
+ case "Export":
263
+ postQueuedResponse({
264
+ type: "ForEvolu",
265
+ evoluPortId: request.evoluPortId,
266
+ message: {
267
+ type: "Export",
268
+ file: dbDeps.sqlite.export(),
269
+ },
270
+ });
271
+ break;
272
+
273
+ default:
274
+ exhaustiveCheck(request.message);
275
+ }
276
+ break;
277
+ }
418
278
 
419
- clock = createClock(deps)();
279
+ case "ForSharedWorker": {
280
+ switch (request.message.type) {
281
+ case "CreateSyncMessages": {
282
+ const protocolMessagesByOwnerId = new Map<
283
+ OwnerId,
284
+ ProtocolMessage
285
+ >();
286
+
287
+ for (const owner of request.message.owners) {
288
+ storage.setOwnerState(owner.encryptionKey);
289
+ const protocolMessage = createProtocolMessageForSync({
290
+ storage,
291
+ console,
292
+ })(owner.id, SubscriptionFlags.Subscribe);
293
+
294
+ if (protocolMessage) {
295
+ protocolMessagesByOwnerId.set(owner.id, protocolMessage);
296
+ }
297
+ }
298
+
299
+ postQueuedResponse({
300
+ type: "ForSharedWorker",
301
+ message: {
302
+ type: "CreateSyncMessages",
303
+ protocolMessagesByOwnerId,
304
+ },
305
+ });
306
+ break;
307
+ }
308
+
309
+ case "ApplySyncMessage": {
310
+ const { owner, inputMessage } = request.message;
311
+
312
+ runWithStorage<void, never>(async (run) => {
313
+ storage.setOwnerState(owner.encryptionKey);
314
+
315
+ const result = await run(
316
+ applyProtocolMessageAsClient(inputMessage, {
317
+ writeKey: owner.writeKey,
318
+ }),
319
+ );
320
+
321
+ const didWriteMessages = storage.didWriteMessages();
322
+
323
+ postQueuedResponse({
324
+ type: "ForSharedWorker",
325
+ message: {
326
+ type: "ApplySyncMessage",
327
+ ownerId: owner.id,
328
+ didWriteMessages,
329
+ result,
330
+ },
331
+ });
332
+
333
+ return ok();
334
+ });
335
+ break;
336
+ }
337
+
338
+ default:
339
+ exhaustiveCheck(request.message);
340
+ }
341
+ break;
342
+ }
420
343
 
421
- const result = initializeDb(deps)(appOwner, clock.get());
422
- if (!result.ok) return result;
423
- }
344
+ default:
345
+ exhaustiveCheck(request);
346
+ }
347
+ };
424
348
 
425
- {
426
- const result = ensureDbSchema(deps)(initMessage.dbSchema, dbSchema.value);
427
- if (!result.ok) return result;
428
- }
349
+ return ok();
429
350
 
430
- {
431
- const result = ensureMessageQuarantineTable(deps);
432
- if (!result.ok) return result;
433
- }
351
+ // TODO: Add parallel stale-leader detection.
352
+ // Heartbeat is emitted by the active DB worker and sent to
353
+ // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu
354
+ // name and if silent for 10 seconds, it waits for another DB worker
355
+ // to announce itself alive and then routes requests to that worker.
356
+ };
434
357
 
435
- const sync = createSync({
436
- ...deps,
437
- clock,
438
- symmetricCrypto: createSymmetricCrypto(platformDeps),
439
- timestampConfig: initMessage.config,
440
- dbSchema: initMessage.dbSchema,
441
- })({
442
- appOwner,
443
- transports: initMessage.config.transports,
444
- onError: (error) => {
445
- postMessage({ type: "onError", error });
446
- },
447
- onReceive: () => {
448
- postMessage({ type: "refreshQueries" });
449
- },
450
- });
451
- if (!sync.ok) return sync;
358
+ /**
359
+ * Hybrid Logical Clock. Keeps the current timestamp in memory to avoid frequent
360
+ * SQLite reads.
361
+ */
362
+ interface Clock {
363
+ readonly get: () => Timestamp;
364
+ readonly save: (timestamp: Timestamp) => void;
365
+ }
452
366
 
453
- {
454
- const result = tryApplyQuarantinedMessages({
455
- ...deps,
456
- dbSchema: initMessage.dbSchema,
457
- })();
458
- if (!result.ok) return result;
367
+ interface ClockDep {
368
+ readonly clock: Clock;
369
+ }
370
+
371
+ const createClock =
372
+ (deps: RandomBytesDep & SqliteDep) =>
373
+ (dbIsInitialized: boolean): Clock => {
374
+ let currentTimestamp: Timestamp;
375
+
376
+ if (dbIsInitialized) {
377
+ const { rows } = deps.sqlite.exec<{ clock: TimestampBytes }>(sql`
378
+ select clock
379
+ from evolu_config
380
+ limit 1;
381
+ `);
382
+ assertNonEmptyReadonlyArray(rows);
383
+ currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock);
384
+ } else {
385
+ currentTimestamp = createInitialTimestamp(deps);
459
386
  }
460
387
 
461
- sync.value.useOwner(true, appOwner);
388
+ return {
389
+ get: () => currentTimestamp,
462
390
 
463
- return ok({
464
- ...deps,
465
- getQueryRowsCache: createGetQueryRowsCache(),
466
- postMessage,
467
- sync: sync.value,
468
- appOwner,
469
- });
470
- });
471
- };
391
+ save: (timestamp) => {
392
+ currentTimestamp = timestamp;
393
+
394
+ deps.sqlite.exec(sql.prepared`
395
+ update evolu_config
396
+ set "clock" = ${timestampToTimestampBytes(timestamp)};
397
+ `);
398
+ },
399
+ };
400
+ };
472
401
 
473
402
  const initializeDb =
474
- (deps: SqliteDep) =>
475
- (
476
- initialAppOwner: AppOwner,
477
- initialClock: Timestamp,
478
- ): Result<void, SqliteError> => {
403
+ ({ sqlite }: SqliteDep) =>
404
+ (initialClock: Timestamp): void => {
479
405
  for (const query of [
480
- // Never change structure to ensure all versions can read it.
481
406
  sql`
482
407
  create table evolu_version (
483
408
  "protocolVersion" integer not null
@@ -492,32 +417,14 @@ const initializeDb =
492
417
 
493
418
  sql`
494
419
  create table evolu_config (
495
- "clock" blob not null,
496
- "appOwnerId" text not null,
497
- "appOwnerEncryptionKey" blob not null,
498
- "appOwnerWriteKey" blob not null,
499
- "appOwnerMnemonic" text
420
+ "clock" blob not null
500
421
  )
501
422
  strict;
502
423
  `,
503
424
 
504
425
  sql`
505
- insert into evolu_config
506
- (
507
- "clock",
508
- "appOwnerId",
509
- "appOwnerEncryptionKey",
510
- "appOwnerWriteKey",
511
- "appOwnerMnemonic"
512
- )
513
- values
514
- (
515
- ${timestampToTimestampBytes(initialClock)},
516
- ${initialAppOwner.id},
517
- ${initialAppOwner.encryptionKey},
518
- ${initialAppOwner.writeKey},
519
- ${initialAppOwner.mnemonic ?? null}
520
- );
426
+ insert into evolu_config ("clock")
427
+ values (${timestampToTimestampBytes(initialClock)});
521
428
  `,
522
429
 
523
430
  /**
@@ -556,193 +463,560 @@ const initializeDb =
556
463
  "timestamp" desc
557
464
  );
558
465
  `,
466
+
467
+ /**
468
+ * Stores messages with unknown schema in a quarantine table.
469
+ *
470
+ * When a device receives sync messages containing tables or columns that
471
+ * don't exist in its current schema (e.g., from a newer app version),
472
+ * those messages are stored here instead of being discarded. This enables
473
+ * forward compatibility:
474
+ *
475
+ * 1. Unknown data is preserved and can be applied when the app is updated
476
+ * 2. Messages are still propagated to other devices that may understand them
477
+ * 3. Partial messages work - known columns go to app tables, unknown to
478
+ * quarantine
479
+ *
480
+ * The `union all` query in `readDbChange` combines `evolu_history` and
481
+ * this table, ensuring all data (known and unknown) is included when
482
+ * syncing to other devices.
483
+ */
484
+ sql`
485
+ create table evolu_message_quarantine (
486
+ "ownerId" blob not null,
487
+ "timestamp" blob not null,
488
+ "table" text not null,
489
+ "id" blob not null,
490
+ "column" text not null,
491
+ "value" any,
492
+ primary key ("ownerId", "timestamp", "table", "id", "column")
493
+ )
494
+ strict;
495
+ `,
559
496
  ]) {
560
- const result = deps.sqlite.exec(query);
561
- if (!result.ok) return result;
497
+ sqlite.exec(query);
562
498
  }
563
499
 
564
- const result = createBaseSqliteStorageTables(deps);
565
- if (!result.ok) return result;
500
+ createBaseSqliteStorageTables({ sqlite });
501
+ };
502
+
503
+ const tryApplyQuarantinedMessages = (
504
+ deps: SqliteDep & SqliteSchemaDep,
505
+ ): void => {
506
+ const rows = deps.sqlite.exec<{
507
+ readonly ownerId: OwnerIdBytes;
508
+ readonly timestamp: TimestampBytes;
509
+ readonly table: string;
510
+ readonly id: IdBytes;
511
+ readonly column: string;
512
+ readonly value: SqliteValue;
513
+ }>(sql`
514
+ select "ownerId", "timestamp", "table", "id", "column", "value"
515
+ from evolu_message_quarantine;
516
+ `);
566
517
 
567
- return ok();
518
+ for (const row of rows.rows) {
519
+ if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue;
520
+
521
+ applyColumnChange(deps)(
522
+ row.ownerId,
523
+ ownerIdBytesToOwnerId(row.ownerId),
524
+ row.table,
525
+ row.id,
526
+ idBytesToId(row.id),
527
+ row.column,
528
+ row.value,
529
+ row.timestamp,
530
+ );
531
+
532
+ deps.sqlite.exec(sql`
533
+ delete from evolu_message_quarantine
534
+ where
535
+ "ownerId" = ${row.ownerId}
536
+ and "timestamp" = ${row.timestamp}
537
+ and "table" = ${row.table}
538
+ and "id" = ${row.id}
539
+ and "column" = ${row.column};
540
+ `);
541
+ }
542
+ };
543
+
544
+ const validateColumnValue =
545
+ (deps: SqliteSchemaDep) =>
546
+ (table: string, column: string, _value: SqliteValue): boolean => {
547
+ const schemaColumns = getProperty(deps.sqliteSchema.tables, table);
548
+ return (
549
+ schemaColumns != null &&
550
+ (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column))
551
+ );
552
+ };
553
+
554
+ const systemColumnsWithoutOwnerId = systemColumns.difference(
555
+ new Set(["ownerId"]),
556
+ );
557
+
558
+ const applyColumnChange =
559
+ (deps: SqliteDep) =>
560
+ (
561
+ ownerIdBytes: OwnerIdBytes,
562
+ ownerId: OwnerId,
563
+ table: string,
564
+ idBytes: IdBytes,
565
+ id: Id,
566
+ column: string,
567
+ value: SqliteValue,
568
+ timestampBytes: TimestampBytes,
569
+ ): void => {
570
+ deps.sqlite.exec(sql.prepared`
571
+ with
572
+ existingTimestamp as (
573
+ select 1
574
+ from evolu_history
575
+ where
576
+ "ownerId" = ${ownerIdBytes}
577
+ and "table" = ${table}
578
+ and "id" = ${idBytes}
579
+ and "column" = ${column}
580
+ and "timestamp" >= ${timestampBytes}
581
+ limit 1
582
+ )
583
+ insert into ${sql.identifier(table)}
584
+ ("ownerId", "id", ${sql.identifier(column)})
585
+ select ${ownerId}, ${id}, ${value}
586
+ where not exists (select 1 from existingTimestamp)
587
+ on conflict ("ownerId", "id") do update
588
+ set ${sql.identifier(column)} = ${value}
589
+ where not exists (select 1 from existingTimestamp);
590
+ `);
591
+
592
+ deps.sqlite.exec(sql.prepared`
593
+ insert into evolu_history
594
+ ("ownerId", "table", "id", "column", "value", "timestamp")
595
+ values
596
+ (
597
+ ${ownerIdBytes},
598
+ ${table},
599
+ ${idBytes},
600
+ ${column},
601
+ ${value},
602
+ ${timestampBytes}
603
+ )
604
+ on conflict do nothing;
605
+ `);
568
606
  };
569
607
 
570
608
  /**
571
- * Ensures the quarantine table exists for storing messages with unknown schema.
572
- *
573
- * When a device receives sync messages containing tables or columns that don't
574
- * exist in its current schema (e.g., from a newer app version), those messages
575
- * are stored here instead of being discarded. This enables forward
576
- * compatibility:
577
- *
578
- * 1. Unknown data is preserved and can be applied when the app is updated
579
- * 2. Messages are still propagated to other devices that may understand them
580
- * 3. Partial messages work - known columns go to app tables, unknown to quarantine
581
- *
582
- * The `union all` query in `readDbChange` combines `evolu_history` and this
583
- * table, ensuring all data (known and unknown) is included when syncing to
584
- * other devices.
609
+ * The Db worker needs one object that can both satisfy sync code expecting
610
+ * {@link Storage}, expose {@link BaseSqliteStorage} helpers to the local
611
+ * implementation, and switch owner encryption keys between requests.
585
612
  */
586
- const ensureMessageQuarantineTable = (
587
- deps: SqliteDep,
588
- ): Result<void, SqliteError> => {
589
- const result = deps.sqlite.exec(sql`
590
- create table if not exists evolu_message_quarantine (
591
- "ownerId" blob not null,
592
- "timestamp" blob not null,
593
- "table" text not null,
594
- "id" blob not null,
595
- "column" text not null,
596
- "value" any,
597
- primary key ("ownerId", "timestamp", "table", "id", "column")
598
- )
599
- strict;
600
- `);
601
- if (!result.ok) return result;
602
- return ok();
603
- };
613
+ interface ClientStorage extends Storage, BaseSqliteStorage {
614
+ readonly setOwnerState: (encryptionKey: EncryptionKey) => void;
615
+ readonly didWriteMessages: () => boolean;
616
+ }
604
617
 
605
- const handlers: Omit<MessageHandlers<DbWorkerInput, DbWorkerDeps>, "init"> = {
606
- getAppOwner: (deps) => () => {
607
- deps.postMessage({
608
- type: "onGetAppOwner",
609
- appOwner: deps.appOwner,
610
- });
611
- },
618
+ const createClientStorage =
619
+ (
620
+ deps: BaseSqliteStorageDep &
621
+ ClockDep &
622
+ SqliteSchemaDep &
623
+ RandomBytesDep &
624
+ SqliteDep &
625
+ TimeDep &
626
+ TimestampConfigDep,
627
+ ) =>
628
+ ({
629
+ onError,
630
+ }: {
631
+ onError: (
632
+ error:
633
+ | ProtocolInvalidDataError
634
+ | ProtocolTimestampMismatchError
635
+ | DecryptWithXChaCha20Poly1305Error
636
+ | TimestampCounterOverflowError
637
+ | TimestampDriftError
638
+ | TimestampTimeOutOfRangeError,
639
+ ) => void;
640
+ }): ClientStorage => {
641
+ let encryptionKey: EncryptionKey | null = null;
642
+ let didWriteMessages = false;
643
+
644
+ const getEncryptionKey = (): EncryptionKey => {
645
+ assert(encryptionKey != null, "ClientStorage encryption key must be set");
646
+ return encryptionKey;
647
+ };
612
648
 
613
- mutate: (deps) => (message) => {
614
- const mutate = deps.sqlite.transaction(() => {
615
- const syncChanges: Array<MutationChange> = [];
649
+ return {
650
+ ...deps.baseSqliteStorage,
616
651
 
617
- for (const change of message.changes) {
618
- const isLocalOnlyChange = change.table.startsWith("_");
619
- if (isLocalOnlyChange) {
620
- const result = applyLocalOnlyChange(deps)(change);
621
- if (!result.ok) return result;
622
- } else {
623
- syncChanges.push(change);
624
- }
625
- }
652
+ // DEV: ClientStorage was designed when Storage and Sync lived in the
653
+ // same file.
654
+ // This is safe because the worker handles one message at a time. We will
655
+ // refactor it later, we will probably have to change Protocol API.
656
+ setOwnerState: (nextEncryptionKey) => {
657
+ encryptionKey = nextEncryptionKey;
658
+ didWriteMessages = false;
659
+ },
626
660
 
627
- if (isNonEmptyArray(syncChanges)) {
628
- const result = deps.sync.applyChanges(syncChanges);
629
- if (!result.ok) return result;
630
- }
661
+ didWriteMessages: () => didWriteMessages,
631
662
 
632
- // Read writes before commit to update UI ASAP
633
- const queryPatches = loadQueries(deps)(
634
- message.tabId,
635
- message.subscribedQueries,
636
- );
637
- if (!queryPatches.ok) return queryPatches;
638
-
639
- // Update the tab that performed the mutation.
640
- deps.postMessage({
641
- type: "onQueryPatches",
642
- tabId: message.tabId,
643
- queryPatches: queryPatches.value,
644
- onCompleteIds: message.onCompleteIds,
645
- });
663
+ // Not implemented yet.
664
+ validateWriteKey: lazyFalse,
665
+ setWriteKey: lazyVoid,
646
666
 
647
- // Notify other tabs to refresh their queries.
648
- deps.postMessage({ type: "refreshQueries", tabId: message.tabId });
667
+ writeMessages: (ownerIdBytes, encryptedMessages) => () => {
668
+ // TODO: Add quota checking for collaborative scenarios.
669
+ // When receiving messages from other owners via relay broadcast,
670
+ // check if this owner is within quota before accepting the data.
671
+ // This prevents an owner from exceeding storage limits when receiving
672
+ // data shared by other collaborators.
649
673
 
650
- return ok();
651
- });
674
+ const messages: Array<CrdtMessage> = [];
675
+ const currentEncryptionKey = getEncryptionKey();
652
676
 
653
- if (!mutate.ok) {
654
- deps.postMessage({ type: "onError", error: mutate.error });
655
- return;
656
- }
657
- },
677
+ for (const message of encryptedMessages) {
678
+ const change = decryptAndDecodeDbChange(
679
+ message,
680
+ currentEncryptionKey,
681
+ );
682
+ if (!change.ok) {
683
+ onError(change.error);
684
+ return ok();
685
+ }
686
+ messages.push({ timestamp: message.timestamp, change: change.value });
687
+ }
658
688
 
659
- query: (deps) => (message) => {
660
- const queryPatches = loadQueries(deps)(message.tabId, message.queries);
689
+ let clockTimestamp = deps.clock.get();
661
690
 
662
- if (!queryPatches.ok) {
663
- deps.postMessage({ type: "onError", error: queryPatches.error });
664
- return;
665
- }
691
+ for (const message of messages) {
692
+ const nextTimestamp = receiveTimestamp(deps)(
693
+ clockTimestamp,
694
+ message.timestamp,
695
+ );
696
+ if (!nextTimestamp.ok) {
697
+ onError(nextTimestamp.error);
698
+ return ok();
699
+ }
700
+ clockTimestamp = nextTimestamp.value;
701
+ }
666
702
 
667
- deps.postMessage({
668
- type: "onQueryPatches",
669
- tabId: message.tabId,
670
- queryPatches: queryPatches.value,
671
- onCompleteIds: [],
672
- });
673
- },
674
-
675
- reset: (deps) => (message) => {
676
- const result = deps.sqlite.transaction(() => {
677
- const dbSchema = getDbSchema(deps)();
678
- if (!dbSchema.ok) return dbSchema;
679
-
680
- for (const tableName in dbSchema.value.tables) {
681
- /**
682
- * The dropped table is completely removed from the database schema and
683
- * the disk file. The table can not be recovered. All indices and
684
- * triggers associated with the table are also deleted.
685
- * https://sqlite.org/lang_droptable.html
686
- */
687
- const result = deps.sqlite.exec(sql`
688
- drop table ${sql.identifier(tableName)};
703
+ assertNonEmptyReadonlyArray(messages);
704
+
705
+ return deps.sqlite.transaction(() => {
706
+ applyMessages(deps)(ownerIdBytesToOwnerId(ownerIdBytes), messages);
707
+ deps.clock.save(clockTimestamp);
708
+ didWriteMessages = true;
709
+ return ok();
710
+ });
711
+ },
712
+
713
+ readDbChange: (ownerId, timestamp) => {
714
+ const result = deps.sqlite.exec<{
715
+ readonly table: string;
716
+ readonly id: IdBytes;
717
+ readonly column: string;
718
+ readonly value: SqliteValue;
719
+ }>(sql`
720
+ select "table", "id", "column", "value"
721
+ from evolu_history
722
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
723
+ union all
724
+ select "table", "id", "column", "value"
725
+ from evolu_message_quarantine
726
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
689
727
  `);
690
- if (!result.ok) return result;
691
- }
692
728
 
693
- if (message.restore) {
694
- const result = ensureDbSchema(deps)(message.restore.dbSchema);
695
- if (!result.ok) return result;
729
+ const { rows } = result;
730
+ assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
731
+ const firstRow = firstInArray(rows);
732
+
733
+ const values = createRecord<string, SqliteValue>();
734
+ let isInsert: DbChange["isInsert"] = false;
735
+ let isDelete: DbChange["isDelete"] = null;
736
+
737
+ for (const r of rows) {
738
+ switch (r.column) {
739
+ case "createdAt":
740
+ isInsert = true;
741
+ break;
742
+ case "updatedAt":
743
+ isInsert = false;
744
+ break;
745
+ case "isDeleted":
746
+ if (SqliteBoolean.is(r.value)) {
747
+ isDelete = sqliteBooleanToBoolean(r.value);
748
+ }
749
+ break;
750
+ default:
751
+ values[r.column] = r.value;
752
+ }
753
+ }
754
+
755
+ const message: CrdtMessage = {
756
+ timestamp: timestampBytesToTimestamp(timestamp),
757
+ change: DbChange.orThrow({
758
+ table: firstRow.table,
759
+ id: idBytesToId(firstRow.id),
760
+ values,
761
+ isInsert,
762
+ isDelete,
763
+ }),
764
+ };
765
+
766
+ return encodeAndEncryptDbChange(deps)(message, getEncryptionKey());
767
+ },
768
+ };
769
+ };
770
+
771
+ const handleMutation =
772
+ (
773
+ deps: BaseSqliteStorageDep &
774
+ ClockDep &
775
+ SqliteSchemaDep &
776
+ RandomBytesDep &
777
+ SqliteDep &
778
+ TimeDep &
779
+ TimestampConfigDep,
780
+ ) =>
781
+ (
782
+ message: ExtractType<
783
+ ExtractType<DbWorkerInput["request"], "ForEvolu">["message"],
784
+ "Mutate"
785
+ >,
786
+ ): Result<
787
+ {
788
+ readonly type: "Mutate";
789
+ readonly messagesByOwnerId: ReadonlyMap<
790
+ OwnerId,
791
+ NonEmptyReadonlyArray<CrdtMessage>
792
+ >;
793
+ readonly rowsByQuery: RowsByQueryMap;
794
+ },
795
+ | TimestampDriftError
796
+ | TimestampCounterOverflowError
797
+ | TimestampTimeOutOfRangeError
798
+ > =>
799
+ deps.sqlite.transaction(() => {
800
+ const messagesByOwnerId = new Map<OwnerId, NonEmptyArray<CrdtMessage>>();
801
+ let clockTimestamp = deps.clock.get();
802
+ let clockChanged = false;
803
+
804
+ for (const change of message.changes) {
805
+ if (change.table.startsWith("_")) {
806
+ applyLocalOnlyChange(deps)(change);
807
+ continue;
808
+ }
809
+
810
+ const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
811
+ if (!nextTimestamp.ok) return nextTimestamp;
812
+
813
+ clockTimestamp = nextTimestamp.value;
814
+ clockChanged = true;
696
815
 
697
- const secret = mnemonicToOwnerSecret(message.restore.mnemonic);
698
- const appOwner = createAppOwner(secret);
699
- const clock = createClock(deps)();
816
+ const { ownerId, ...dbChange } = change;
817
+ const message: CrdtMessage = {
818
+ timestamp: clockTimestamp,
819
+ change: dbChange,
820
+ };
700
821
 
701
- return initializeDb(deps)(appOwner, clock.get());
822
+ const messages = messagesByOwnerId.get(ownerId);
823
+ if (messages) messages.push(message);
824
+ else messagesByOwnerId.set(ownerId, [message]);
702
825
  }
703
826
 
704
- return ok();
827
+ for (const [ownerId, messages] of messagesByOwnerId) {
828
+ applyMessages(deps)(ownerId, messages);
829
+ }
830
+
831
+ if (clockChanged) deps.clock.save(clockTimestamp);
832
+
833
+ return ok({
834
+ type: "Mutate",
835
+ messagesByOwnerId,
836
+ rowsByQuery: loadQueries(deps)(message.subscribedQueries),
837
+ });
705
838
  });
706
839
 
707
- if (!result.ok) {
708
- deps.postMessage({ type: "onError", error: result.error });
709
- return;
840
+ const applyLocalOnlyChange =
841
+ (deps: SqliteDep & TimeDep) =>
842
+ (change: MutationChange): void => {
843
+ if (change.isDelete) {
844
+ deps.sqlite.exec(sql`
845
+ delete from ${sql.identifier(change.table)}
846
+ where id = ${change.id};
847
+ `);
848
+ } else {
849
+ const ownerId = change.ownerId;
850
+ const columns = dbChangeToColumns(change, deps.time.now());
851
+
852
+ for (const [column, value] of columns) {
853
+ deps.sqlite.exec(sql.prepared`
854
+ insert into ${sql.identifier(change.table)}
855
+ ("ownerId", "id", ${sql.identifier(column)})
856
+ values (${ownerId}, ${change.id}, ${value})
857
+ on conflict ("ownerId", "id") do update
858
+ set ${sql.identifier(column)} = ${value};
859
+ `);
860
+ }
710
861
  }
862
+ };
711
863
 
712
- deps.postMessage({
713
- type: "onReset",
714
- onCompleteId: message.onCompleteId,
715
- reload: message.reload,
716
- });
717
- },
864
+ const applyMessages =
865
+ (deps: BaseSqliteStorageDep & ClockDep & SqliteSchemaDep & SqliteDep) =>
866
+ (ownerId: OwnerId, messages: NonEmptyReadonlyArray<CrdtMessage>): void => {
867
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
718
868
 
719
- ensureDbSchema: (deps) => (message) => {
720
- const result = deps.sqlite.transaction(() =>
721
- ensureDbSchema(deps)(message.dbSchema),
869
+ const usage = getOwnerUsage(deps)(
870
+ ownerIdBytes,
871
+ timestampToTimestampBytes(firstInArray(messages).timestamp),
722
872
  );
873
+ if (!usage.ok) return;
874
+
875
+ let { firstTimestamp, lastTimestamp } = usage.value;
876
+
877
+ for (const { timestamp, change } of messages) {
878
+ const columns = dbChangeToColumns(change, timestamp.millis);
879
+ const idBytes = idToIdBytes(change.id);
880
+ const timestampBytes = timestampToTimestampBytes(timestamp);
881
+
882
+ for (const [column, value] of columns) {
883
+ if (validateColumnValue(deps)(change.table, column, value)) {
884
+ applyColumnChange(deps)(
885
+ ownerIdBytes,
886
+ ownerId,
887
+ change.table,
888
+ idBytes,
889
+ change.id,
890
+ column,
891
+ value,
892
+ timestampBytes,
893
+ );
894
+ } else {
895
+ deps.sqlite.exec(sql.prepared`
896
+ insert into evolu_message_quarantine
897
+ ("ownerId", "timestamp", "table", "id", "column", "value")
898
+ values
899
+ (
900
+ ${ownerIdBytes},
901
+ ${timestampBytes},
902
+ ${change.table},
903
+ ${idBytes},
904
+ ${column},
905
+ ${value}
906
+ )
907
+ on conflict do nothing;
908
+ `);
909
+ }
910
+ }
723
911
 
724
- if (!result.ok) {
725
- deps.postMessage({ type: "onError", error: result.error });
726
- return;
912
+ let strategy;
913
+ [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
914
+ timestampBytes,
915
+ firstTimestamp,
916
+ lastTimestamp,
917
+ );
918
+
919
+ deps.baseSqliteStorage.insertTimestamp(
920
+ ownerIdBytes,
921
+ timestampBytes,
922
+ strategy,
923
+ );
727
924
  }
728
- },
729
925
 
730
- export: (deps) => (message) => {
731
- const file = deps.sqlite.export();
926
+ /**
927
+ * TODO: Implement proper storedBytes tracking for client using received and
928
+ * sent encrypted message sizes.
929
+ */
930
+ updateOwnerUsage(deps)(
931
+ ownerIdBytes,
932
+ onePositiveInt, // Placeholder until proper tracking implemented
933
+ firstTimestamp,
934
+ lastTimestamp,
935
+ );
936
+ };
937
+
938
+ const dbChangeToColumns = (change: DbChange, now: Millis) => {
939
+ let values = objectToEntries(change.values);
940
+
941
+ // SystemColumns are not encoded in change.values.
942
+ values = appendToArray(values, [
943
+ change.isInsert ? "createdAt" : "updatedAt",
944
+ millisToDateIso(now),
945
+ ]);
946
+ if (change.isDelete != null) {
947
+ values = appendToArray(values, [
948
+ "isDeleted",
949
+ booleanToSqliteBoolean(change.isDelete),
950
+ ]);
951
+ }
952
+
953
+ return values;
954
+ };
732
955
 
733
- if (!file.ok) {
734
- deps.postMessage({ type: "onError", error: file.error });
735
- return;
956
+ const loadQueries =
957
+ (deps: SqliteDep) =>
958
+ (queries: Iterable<Query>): Map<Query, ReadonlyArray<SqliteRow>> => {
959
+ const rowsByQuery = new Map<Query, ReadonlyArray<SqliteRow>>();
960
+
961
+ for (const query of queries) {
962
+ const { rows } = deps.sqlite.exec(sqliteQueryStringToSqliteQuery(query));
963
+ rowsByQuery.set(query, rows);
736
964
  }
737
965
 
738
- deps.postMessage({
739
- type: "onExport",
740
- onCompleteId: message.onCompleteId,
741
- file: file.value,
742
- });
743
- },
966
+ return rowsByQuery;
967
+ };
744
968
 
745
- useOwner: (deps) => (message) => {
746
- deps.sync.useOwner(message.use, message.owner);
747
- },
748
- };
969
+ // reset: (deps) => (message) => {
970
+ // const result = deps.sqlite.transaction(() => {
971
+ // const sqliteSchema = getSqliteSchema(deps)();
972
+ // if (!sqliteSchema.ok) return sqliteSchema;
973
+
974
+ // for (const tableName in sqliteSchema.value.tables) {
975
+ // /**
976
+ // * The dropped table is completely removed from the database schema and
977
+ // * the disk file. The table can not be recovered. All indices and
978
+ // * triggers associated with the table are also deleted.
979
+ // * https://sqlite.org/lang_droptable.html
980
+ // */
981
+ // const result = deps.sqlite.exec(sql`
982
+ // drop table ${sql.identifier(tableName)};
983
+ // `);
984
+ // if (!result.ok) return result;
985
+ // }
986
+
987
+ // if (message.restore) {
988
+ // const result = ensureSqliteSchema(deps)(message.restore.sqliteSchema);
989
+ // if (!result.ok) return result;
990
+
991
+ // const secret = mnemonicToOwnerSecret(message.restore.mnemonic);
992
+ // const appOwner = createAppOwner(secret);
993
+ // const clock = createClock(deps)();
994
+
995
+ // return initializeDb(deps)(appOwner, clock.get());
996
+ // }
997
+
998
+ // return ok();
999
+ // });
1000
+
1001
+ // if (!result.ok) {
1002
+ // deps.postMessage({ type: "onError", error: result.error });
1003
+ // return;
1004
+ // }
1005
+
1006
+ // deps.postMessage({
1007
+ // type: "onReset",
1008
+ // onCompleteId: message.onCompleteId,
1009
+ // reload: message.reload,
1010
+ // });
1011
+ // },
1012
+
1013
+ // ensureSqliteSchema: (deps) => (message) => {
1014
+ // const result = deps.sqlite.transaction(() =>
1015
+ // ensureSqliteSchema(deps)(message.sqliteSchema),
1016
+ // );
1017
+
1018
+ // if (!result.ok) {
1019
+ // deps.postMessage({ type: "onError", error: result.error });
1020
+ // return;
1021
+ // }
1022
+ // },