@evolu/common 7.4.1 → 8.0.0-next.1

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 (250) 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 +162 -0
  42. package/dist/src/Lookup.d.ts.map +1 -0
  43. package/dist/src/Lookup.js +200 -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 +29 -0
  63. package/dist/src/Polyfills.d.ts.map +1 -0
  64. package/dist/src/Polyfills.js +329 -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 +2274 -423
  105. package/dist/src/Task.d.ts.map +1 -1
  106. package/dist/src/Task.js +1499 -348
  107. package/dist/src/Test.d.ts +150 -0
  108. package/dist/src/Test.d.ts.map +1 -0
  109. package/dist/src/Test.js +97 -0
  110. package/dist/src/Time.d.ts +202 -48
  111. package/dist/src/Time.d.ts.map +1 -1
  112. package/dist/src/Time.js +199 -88
  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 +587 -209
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +370 -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 +130 -58
  123. package/dist/src/WebSocket.d.ts.map +1 -1
  124. package/dist/src/WebSocket.js +274 -113
  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 +621 -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 +34 -49
  153. package/dist/src/local-first/Relay.d.ts.map +1 -1
  154. package/dist/src/local-first/Relay.js +59 -177
  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 +54 -58
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +127 -179
  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 +37 -24
  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 +429 -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 +520 -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 +3696 -740
  206. package/src/Test.ts +202 -0
  207. package/src/Time.ts +357 -147
  208. package/src/Tracer.ts +54 -0
  209. package/src/Type.ts +866 -430
  210. package/src/Types.ts +202 -7
  211. package/src/WebSocket.ts +404 -167
  212. package/src/Worker.ts +527 -147
  213. package/src/index.ts +74 -3
  214. package/src/local-first/Db.ts +867 -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 +441 -307
  220. package/src/local-first/Query.ts +243 -194
  221. package/src/local-first/Relay.ts +119 -273
  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 +211 -283
  225. package/src/local-first/Timestamp.ts +38 -45
  226. package/src/local-first/index.ts +2 -16
  227. package/dist/src/Instances.d.ts +0 -34
  228. package/dist/src/Instances.d.ts.map +0 -1
  229. package/dist/src/Instances.js +0 -44
  230. package/dist/src/Resources.d.ts +0 -118
  231. package/dist/src/Resources.d.ts.map +0 -1
  232. package/dist/src/Resources.js +0 -197
  233. package/dist/src/local-first/Platform.d.ts +0 -25
  234. package/dist/src/local-first/Platform.d.ts.map +0 -1
  235. package/dist/src/local-first/Platform.js +0 -1
  236. package/dist/src/local-first/Public.d.ts +0 -22
  237. package/dist/src/local-first/Public.d.ts.map +0 -1
  238. package/dist/src/local-first/Public.js +0 -15
  239. package/dist/src/local-first/PublicKysely.d.ts +0 -148
  240. package/dist/src/local-first/PublicKysely.d.ts.map +0 -1
  241. package/dist/src/local-first/PublicKysely.js +0 -184
  242. package/dist/src/local-first/Sync.d.ts +0 -112
  243. package/dist/src/local-first/Sync.d.ts.map +0 -1
  244. package/dist/src/local-first/Sync.js +0 -526
  245. package/src/Instances.ts +0 -92
  246. package/src/Resources.ts +0 -367
  247. package/src/local-first/Platform.ts +0 -27
  248. package/src/local-first/Public.ts +0 -42
  249. package/src/local-first/PublicKysely.ts +0 -239
  250. package/src/local-first/Sync.ts +0 -949
@@ -1,483 +1,406 @@
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, assertType } 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
+ getTimestampInsertStrategy,
81
+ readOwnerUsageOrDefault,
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
- };
123
+ export type DbWorkerDeps = WorkerDeps & LeaderLockDep & CreateSqliteDriverDep;
124
+
125
+ export const startDbWorker =
126
+ (self: WorkerSelf<DbWorkerInit>): Task<void, never, DbWorkerDeps> =>
127
+ async (_run) => {
128
+ await using stack = new AsyncDisposableStack();
129
+ const run = stack.use(_run.create());
130
+ const { deps } = run;
131
+
132
+ let initialized = false;
133
+
134
+ const initMessage = await run.orThrow(
135
+ callback<DbWorkerInit>(({ ok }) => {
136
+ self.onMessage = (message) => {
137
+ assert(!initialized, "DbWorker must be initialized only once");
138
+ initialized = true;
139
+ ok(message);
140
+ };
141
+ }),
142
+ );
307
143
 
308
- export type DbWorkerPlatformDeps = ConsoleDep &
309
- 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
- }
144
+ const console = deps.console.child(initMessage.name).child("DbWorker");
145
+ console.setLevel(initMessage.consoleLevel);
146
+ console.info("start DbWorker");
328
147
 
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
- );
148
+ const port = stack.use(
149
+ deps.createMessagePort<DbWorkerOutput, DbWorkerInput>(initMessage.port),
150
+ );
345
151
 
346
- if (!deps.ok) {
347
- postMessage({ type: "onError", error: deps.error });
348
- return null;
349
- }
152
+ stack.defer(
153
+ deps.consoleStoreOutputEntry.subscribe(() => {
154
+ const entry = deps.consoleStoreOutputEntry.get();
155
+ if (entry) port.postMessage({ type: "OnConsoleEntry", entry });
156
+ }),
157
+ );
350
158
 
351
- return deps.value;
352
- },
353
- handlers,
354
- });
159
+ stack.use(await run.orThrow(deps.leaderLock.lock(initMessage.name)));
160
+ if (stack.disposed) return ok();
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
+ port.postMessage({ type: "LeaderAcquired", name: initMessage.name });
366
163
 
367
- const deps = { ...platformDeps, sqlite: sqlite.value };
164
+ const sqlite = stack.use(
165
+ await run.orThrow(
166
+ createSqlite(
167
+ initMessage.name,
168
+ initMessage.memoryOnly
169
+ ? { mode: "memory" }
170
+ : { mode: "encrypted", encryptionKey: initMessage.encryptionKey },
171
+ ),
172
+ ),
173
+ );
174
+ console.debug("SQLite created");
368
175
 
369
- return deps.sqlite.transaction(() => {
370
- const dbSchema = getDbSchema(deps)();
371
- if (!dbSchema.ok) return dbSchema;
176
+ const baseSqliteStorage = createBaseSqliteStorage({ sqlite, ...run.deps });
372
177
 
373
- const dbIsInitialized = "evolu_version" in dbSchema.value.tables;
178
+ const dbDeps = {
179
+ ...run.deps,
180
+ sqlite,
181
+ sqliteSchema: initMessage.sqliteSchema,
182
+ baseSqliteStorage,
183
+ timestampConfig: { maxDrift: defaultTimestampMaxDrift },
184
+ };
374
185
 
375
- let appOwner: AppOwner;
376
- let clock: Clock;
186
+ const currentSchema = getEvoluSqliteSchema(dbDeps)();
187
+ const dbIsInitialized = "evolu_version" in currentSchema.tables;
188
+ const clock = createClock(dbDeps)(dbIsInitialized);
377
189
 
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;
190
+ sqlite.transaction(() => {
191
+ if (!dbIsInitialized) initializeDb(dbDeps)(clock.get());
192
+ ensureSqliteSchema(dbDeps)(initMessage.sqliteSchema, currentSchema);
193
+ tryApplyQuarantinedMessages(dbDeps);
194
+ });
401
195
 
402
- assertNonEmptyReadonlyArray(configResult.value.rows);
403
- const config = firstInArray(configResult.value.rows);
196
+ const storage = createClientStorage({ ...dbDeps, clock })({
197
+ onError: (error) => {
198
+ port.postMessage({ type: "OnError", error });
199
+ },
200
+ });
404
201
 
405
- appOwner = {
406
- type: "AppOwner",
407
- id: config.appOwnerId,
408
- encryptionKey: config.appOwnerEncryptionKey,
409
- writeKey: config.appOwnerWriteKey,
410
- mnemonic: config.appOwnerMnemonic,
202
+ // TODO: Call on dispose message.
203
+ const _moved = stack.move();
204
+ const runWithStorage = run.addDeps({ storage });
205
+
206
+ /**
207
+ * SharedWorker retries until some leader replies, so this worker must
208
+ * ignore callback IDs it already completed.
209
+ *
210
+ * The Set intentionally lives for one leader's lifetime. If that leader tab
211
+ * closes, retries go to a new leader with a fresh Set. Revisit this only if
212
+ * memory pressure shows up; even millions of short callback IDs are
213
+ * acceptable here, and naive eviction could let a still-retried request run
214
+ * again.
215
+ */
216
+ const processedRequestIds = new Set<Id>();
217
+
218
+ port.onMessage = ({ callbackId, request }) => {
219
+ if (processedRequestIds.has(callbackId)) return;
220
+ processedRequestIds.add(callbackId);
221
+
222
+ const postQueuedResponse = (
223
+ response: ExtractType<DbWorkerOutput, "OnQueuedResponse">["response"],
224
+ ): void => {
225
+ port.postMessage(
226
+ { type: "OnQueuedResponse", callbackId, response },
227
+ response.type === "ForEvolu" && response.message.type === "Export"
228
+ ? [response.message.file.buffer]
229
+ : undefined,
230
+ );
411
231
  };
412
232
 
413
- clock = createClock(deps)(timestampBytesToTimestamp(config.clock));
414
- } else {
415
- appOwner =
416
- initMessage.config.externalAppOwner ??
417
- createAppOwner(createOwnerSecret(platformDeps));
233
+ switch (request.type) {
234
+ case "ForEvolu": {
235
+ switch (request.message.type) {
236
+ case "Mutate": {
237
+ const result = handleMutation({ ...dbDeps, clock })(
238
+ request.message,
239
+ );
240
+ if (!result.ok) {
241
+ port.postMessage({ type: "OnError", error: result.error });
242
+ } else {
243
+ postQueuedResponse({
244
+ type: "ForEvolu",
245
+ evoluPortId: request.evoluPortId,
246
+ message: result.value,
247
+ });
248
+ }
249
+ break;
250
+ }
251
+
252
+ case "Query":
253
+ postQueuedResponse({
254
+ type: "ForEvolu",
255
+ evoluPortId: request.evoluPortId,
256
+ message: {
257
+ type: "Query",
258
+ rowsByQuery: loadQueries(dbDeps)(request.message.queries),
259
+ },
260
+ });
261
+ break;
262
+
263
+ case "Export":
264
+ postQueuedResponse({
265
+ type: "ForEvolu",
266
+ evoluPortId: request.evoluPortId,
267
+ message: {
268
+ type: "Export",
269
+ file: dbDeps.sqlite.export(),
270
+ },
271
+ });
272
+ break;
273
+
274
+ default:
275
+ exhaustiveCheck(request.message);
276
+ }
277
+ break;
278
+ }
418
279
 
419
- clock = createClock(deps)();
280
+ case "ForSharedWorker": {
281
+ switch (request.message.type) {
282
+ case "CreateSyncMessages": {
283
+ const protocolMessagesByOwnerId = new Map<
284
+ OwnerId,
285
+ ProtocolMessage
286
+ >();
287
+
288
+ for (const owner of request.message.owners) {
289
+ storage.setRequestContext(owner.encryptionKey);
290
+ const protocolMessage = createProtocolMessageForSync({
291
+ storage,
292
+ console,
293
+ })(owner.id, SubscriptionFlags.Subscribe);
294
+ protocolMessagesByOwnerId.set(owner.id, protocolMessage);
295
+ }
296
+
297
+ postQueuedResponse({
298
+ type: "ForSharedWorker",
299
+ message: {
300
+ type: "CreateSyncMessages",
301
+ protocolMessagesByOwnerId,
302
+ },
303
+ });
304
+ break;
305
+ }
306
+
307
+ case "ApplySyncMessage": {
308
+ const { owner, inputMessage } = request.message;
309
+
310
+ runWithStorage<void, never>(async (run) => {
311
+ storage.setRequestContext(owner.encryptionKey);
312
+
313
+ const result = await run(
314
+ applyProtocolMessageAsClient(inputMessage, {
315
+ writeKey: owner.writeKey,
316
+ }),
317
+ );
318
+
319
+ const didWriteMessages = storage.didWriteMessages();
320
+
321
+ postQueuedResponse({
322
+ type: "ForSharedWorker",
323
+ message: {
324
+ type: "ApplySyncMessage",
325
+ ownerId: owner.id,
326
+ didWriteMessages,
327
+ result,
328
+ },
329
+ });
330
+
331
+ return ok();
332
+ });
333
+ break;
334
+ }
335
+
336
+ default:
337
+ exhaustiveCheck(request.message);
338
+ }
339
+ break;
340
+ }
420
341
 
421
- const result = initializeDb(deps)(appOwner, clock.get());
422
- if (!result.ok) return result;
423
- }
342
+ default:
343
+ exhaustiveCheck(request);
344
+ }
345
+ };
424
346
 
425
- {
426
- const result = ensureDbSchema(deps)(initMessage.dbSchema, dbSchema.value);
427
- if (!result.ok) return result;
428
- }
347
+ return ok();
429
348
 
430
- {
431
- const result = ensureMessageQuarantineTable(deps);
432
- if (!result.ok) return result;
433
- }
349
+ // TODO: Add parallel stale-leader detection.
350
+ // Heartbeat is emitted by the active DB worker and sent to
351
+ // SharedWorker. SharedWorker tracks last-seen heartbeat per Evolu
352
+ // name and if silent for 10 seconds, it waits for another DB worker
353
+ // to announce itself alive and then routes requests to that worker.
354
+ };
434
355
 
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;
356
+ /**
357
+ * Hybrid Logical Clock. Keeps the current timestamp in memory to avoid frequent
358
+ * SQLite reads.
359
+ */
360
+ interface Clock {
361
+ readonly get: () => Timestamp;
362
+ readonly save: (timestamp: Timestamp) => void;
363
+ }
452
364
 
453
- {
454
- const result = tryApplyQuarantinedMessages({
455
- ...deps,
456
- dbSchema: initMessage.dbSchema,
457
- })();
458
- if (!result.ok) return result;
365
+ interface ClockDep {
366
+ readonly clock: Clock;
367
+ }
368
+
369
+ const createClock =
370
+ (deps: RandomBytesDep & SqliteDep) =>
371
+ (dbIsInitialized: boolean): Clock => {
372
+ let currentTimestamp: Timestamp;
373
+
374
+ if (dbIsInitialized) {
375
+ const { rows } = deps.sqlite.exec<{ clock: TimestampBytes }>(sql`
376
+ select clock
377
+ from evolu_config
378
+ limit 1;
379
+ `);
380
+ assertNonEmptyReadonlyArray(rows);
381
+ currentTimestamp = timestampBytesToTimestamp(firstInArray(rows).clock);
382
+ } else {
383
+ currentTimestamp = createInitialTimestamp(deps);
459
384
  }
460
385
 
461
- sync.value.useOwner(true, appOwner);
386
+ return {
387
+ get: () => currentTimestamp,
462
388
 
463
- return ok({
464
- ...deps,
465
- getQueryRowsCache: createGetQueryRowsCache(),
466
- postMessage,
467
- sync: sync.value,
468
- appOwner,
469
- });
470
- });
471
- };
389
+ save: (timestamp) => {
390
+ currentTimestamp = timestamp;
391
+
392
+ deps.sqlite.exec(sql.prepared`
393
+ update evolu_config
394
+ set "clock" = ${timestampToTimestampBytes(timestamp)};
395
+ `);
396
+ },
397
+ };
398
+ };
472
399
 
473
400
  const initializeDb =
474
- (deps: SqliteDep) =>
475
- (
476
- initialAppOwner: AppOwner,
477
- initialClock: Timestamp,
478
- ): Result<void, SqliteError> => {
401
+ ({ sqlite }: SqliteDep) =>
402
+ (initialClock: Timestamp): void => {
479
403
  for (const query of [
480
- // Never change structure to ensure all versions can read it.
481
404
  sql`
482
405
  create table evolu_version (
483
406
  "protocolVersion" integer not null
@@ -492,32 +415,14 @@ const initializeDb =
492
415
 
493
416
  sql`
494
417
  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
418
+ "clock" blob not null
500
419
  )
501
420
  strict;
502
421
  `,
503
422
 
504
423
  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
- );
424
+ insert into evolu_config ("clock")
425
+ values (${timestampToTimestampBytes(initialClock)});
521
426
  `,
522
427
 
523
428
  /**
@@ -556,193 +461,558 @@ const initializeDb =
556
461
  "timestamp" desc
557
462
  );
558
463
  `,
464
+
465
+ /**
466
+ * Stores messages with unknown schema in a quarantine table.
467
+ *
468
+ * When a device receives sync messages containing tables or columns that
469
+ * don't exist in its current schema (e.g., from a newer app version),
470
+ * those messages are stored here instead of being discarded. This enables
471
+ * forward compatibility:
472
+ *
473
+ * 1. Unknown data is preserved and can be applied when the app is updated
474
+ * 2. Messages are still propagated to other devices that may understand them
475
+ * 3. Partial messages work - known columns go to app tables, unknown to
476
+ * quarantine
477
+ *
478
+ * The `union all` query in `readDbChange` combines `evolu_history` and
479
+ * this table, ensuring all data (known and unknown) is included when
480
+ * syncing to other devices.
481
+ */
482
+ sql`
483
+ create table evolu_message_quarantine (
484
+ "ownerId" blob not null,
485
+ "timestamp" blob not null,
486
+ "table" text not null,
487
+ "id" blob not null,
488
+ "column" text not null,
489
+ "value" any,
490
+ primary key ("ownerId", "timestamp", "table", "id", "column")
491
+ )
492
+ strict;
493
+ `,
559
494
  ]) {
560
- const result = deps.sqlite.exec(query);
561
- if (!result.ok) return result;
495
+ sqlite.exec(query);
562
496
  }
563
497
 
564
- const result = createBaseSqliteStorageTables(deps);
565
- if (!result.ok) return result;
498
+ createBaseSqliteStorageTables({ sqlite });
499
+ };
566
500
 
567
- return ok();
501
+ const tryApplyQuarantinedMessages = (
502
+ deps: SqliteDep & SqliteSchemaDep,
503
+ ): void => {
504
+ const rows = deps.sqlite.exec<{
505
+ readonly ownerId: OwnerIdBytes;
506
+ readonly timestamp: TimestampBytes;
507
+ readonly table: string;
508
+ readonly id: IdBytes;
509
+ readonly column: string;
510
+ readonly value: SqliteValue;
511
+ }>(sql`
512
+ select "ownerId", "timestamp", "table", "id", "column", "value"
513
+ from evolu_message_quarantine;
514
+ `);
515
+
516
+ for (const row of rows.rows) {
517
+ if (!validateColumnValue(deps)(row.table, row.column, row.value)) continue;
518
+
519
+ applyColumnChange(deps)(
520
+ row.ownerId,
521
+ ownerIdBytesToOwnerId(row.ownerId),
522
+ row.table,
523
+ row.id,
524
+ idBytesToId(row.id),
525
+ row.column,
526
+ row.value,
527
+ row.timestamp,
528
+ );
529
+
530
+ deps.sqlite.exec(sql`
531
+ delete from evolu_message_quarantine
532
+ where
533
+ "ownerId" = ${row.ownerId}
534
+ and "timestamp" = ${row.timestamp}
535
+ and "table" = ${row.table}
536
+ and "id" = ${row.id}
537
+ and "column" = ${row.column};
538
+ `);
539
+ }
540
+ };
541
+
542
+ const validateColumnValue =
543
+ (deps: SqliteSchemaDep) =>
544
+ (table: string, column: string, _value: SqliteValue): boolean => {
545
+ const schemaColumns = getProperty(deps.sqliteSchema.tables, table);
546
+ return (
547
+ schemaColumns != null &&
548
+ (systemColumnsWithoutOwnerId.has(column) || schemaColumns.has(column))
549
+ );
550
+ };
551
+
552
+ const systemColumnsWithoutOwnerId = systemColumns.difference(
553
+ new Set(["ownerId"]),
554
+ );
555
+
556
+ const applyColumnChange =
557
+ (deps: SqliteDep) =>
558
+ (
559
+ ownerIdBytes: OwnerIdBytes,
560
+ ownerId: OwnerId,
561
+ table: string,
562
+ idBytes: IdBytes,
563
+ id: Id,
564
+ column: string,
565
+ value: SqliteValue,
566
+ timestampBytes: TimestampBytes,
567
+ ): void => {
568
+ deps.sqlite.exec(sql.prepared`
569
+ with
570
+ existingTimestamp as (
571
+ select 1
572
+ from evolu_history
573
+ where
574
+ "ownerId" = ${ownerIdBytes}
575
+ and "table" = ${table}
576
+ and "id" = ${idBytes}
577
+ and "column" = ${column}
578
+ and "timestamp" >= ${timestampBytes}
579
+ limit 1
580
+ )
581
+ insert into ${sql.identifier(table)}
582
+ ("ownerId", "id", ${sql.identifier(column)})
583
+ select ${ownerId}, ${id}, ${value}
584
+ where not exists (select 1 from existingTimestamp)
585
+ on conflict ("ownerId", "id") do update
586
+ set ${sql.identifier(column)} = ${value}
587
+ where not exists (select 1 from existingTimestamp);
588
+ `);
589
+
590
+ deps.sqlite.exec(sql.prepared`
591
+ insert into evolu_history
592
+ ("ownerId", "table", "id", "column", "value", "timestamp")
593
+ values
594
+ (
595
+ ${ownerIdBytes},
596
+ ${table},
597
+ ${idBytes},
598
+ ${column},
599
+ ${value},
600
+ ${timestampBytes}
601
+ )
602
+ on conflict do nothing;
603
+ `);
568
604
  };
569
605
 
570
606
  /**
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.
607
+ * The Db worker needs one object that can both satisfy sync code expecting
608
+ * {@link Storage}, expose {@link BaseSqliteStorage} helpers to the local
609
+ * implementation, and switch owner encryption keys between requests.
585
610
  */
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
- };
611
+ interface ClientStorage extends Storage, BaseSqliteStorage {
612
+ readonly setRequestContext: (encryptionKey: EncryptionKey) => void;
613
+ readonly didWriteMessages: () => boolean;
614
+ }
604
615
 
605
- const handlers: Omit<MessageHandlers<DbWorkerInput, DbWorkerDeps>, "init"> = {
606
- getAppOwner: (deps) => () => {
607
- deps.postMessage({
608
- type: "onGetAppOwner",
609
- appOwner: deps.appOwner,
610
- });
611
- },
616
+ const createClientStorage =
617
+ (
618
+ deps: BaseSqliteStorageDep &
619
+ ClockDep &
620
+ SqliteSchemaDep &
621
+ RandomBytesDep &
622
+ SqliteDep &
623
+ TimeDep &
624
+ TimestampConfigDep,
625
+ ) =>
626
+ ({
627
+ onError,
628
+ }: {
629
+ onError: (
630
+ error:
631
+ | ProtocolInvalidDataError
632
+ | ProtocolTimestampMismatchError
633
+ | DecryptWithXChaCha20Poly1305Error
634
+ | TimestampCounterOverflowError
635
+ | TimestampDriftError
636
+ | TimestampTimeOutOfRangeError,
637
+ ) => void;
638
+ }): ClientStorage => {
639
+ let encryptionKey: EncryptionKey | null = null;
640
+ let didWriteMessages = false;
641
+
642
+ const getEncryptionKey = (): EncryptionKey => {
643
+ assert(encryptionKey != null, "ClientStorage encryption key must be set");
644
+ return encryptionKey;
645
+ };
612
646
 
613
- mutate: (deps) => (message) => {
614
- const mutate = deps.sqlite.transaction(() => {
615
- const syncChanges: Array<MutationChange> = [];
647
+ return {
648
+ ...deps.baseSqliteStorage,
616
649
 
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
- }
650
+ // DEV: ClientStorage was designed when Storage and Sync lived in the
651
+ // same file.
652
+ // This is safe because the worker handles one message at a time. We will
653
+ // refactor it later, we will probably have to change Protocol API.
654
+ setRequestContext: (nextEncryptionKey) => {
655
+ encryptionKey = nextEncryptionKey;
656
+ didWriteMessages = false;
657
+ },
626
658
 
627
- if (isNonEmptyArray(syncChanges)) {
628
- const result = deps.sync.applyChanges(syncChanges);
629
- if (!result.ok) return result;
630
- }
659
+ didWriteMessages: () => didWriteMessages,
631
660
 
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
- });
661
+ // Not implemented yet.
662
+ validateWriteKey: lazyFalse,
663
+ setWriteKey: lazyVoid,
646
664
 
647
- // Notify other tabs to refresh their queries.
648
- deps.postMessage({ type: "refreshQueries", tabId: message.tabId });
665
+ writeMessages: (ownerIdBytes, encryptedMessages) => () => {
666
+ // TODO: Add quota checking for collaborative scenarios.
667
+ // When receiving messages from other owners via relay broadcast,
668
+ // check if this owner is within quota before accepting the data.
669
+ // This prevents an owner from exceeding storage limits when receiving
670
+ // data shared by other collaborators.
649
671
 
650
- return ok();
651
- });
672
+ const messages: Array<CrdtMessage> = [];
673
+ const currentEncryptionKey = getEncryptionKey();
652
674
 
653
- if (!mutate.ok) {
654
- deps.postMessage({ type: "onError", error: mutate.error });
655
- return;
656
- }
657
- },
675
+ for (const message of encryptedMessages) {
676
+ const change = decryptAndDecodeDbChange(
677
+ message,
678
+ currentEncryptionKey,
679
+ );
680
+ if (!change.ok) {
681
+ onError(change.error);
682
+ return ok();
683
+ }
684
+ messages.push({ timestamp: message.timestamp, change: change.value });
685
+ }
658
686
 
659
- query: (deps) => (message) => {
660
- const queryPatches = loadQueries(deps)(message.tabId, message.queries);
687
+ let clockTimestamp = deps.clock.get();
661
688
 
662
- if (!queryPatches.ok) {
663
- deps.postMessage({ type: "onError", error: queryPatches.error });
664
- return;
665
- }
689
+ for (const message of messages) {
690
+ const nextTimestamp = receiveTimestamp(deps)(
691
+ clockTimestamp,
692
+ message.timestamp,
693
+ );
694
+ if (!nextTimestamp.ok) {
695
+ onError(nextTimestamp.error);
696
+ return ok();
697
+ }
698
+ clockTimestamp = nextTimestamp.value;
699
+ }
666
700
 
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)};
701
+ assertNonEmptyReadonlyArray(messages);
702
+
703
+ return deps.sqlite.transaction(() => {
704
+ applyMessages(deps)(ownerIdBytesToOwnerId(ownerIdBytes), messages);
705
+ deps.clock.save(clockTimestamp);
706
+ didWriteMessages = true;
707
+ return ok();
708
+ });
709
+ },
710
+
711
+ readDbChange: (ownerId, timestamp) => {
712
+ const result = deps.sqlite.exec<{
713
+ readonly table: string;
714
+ readonly id: IdBytes;
715
+ readonly column: string;
716
+ readonly value: SqliteValue;
717
+ }>(sql`
718
+ select "table", "id", "column", "value"
719
+ from evolu_history
720
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp}
721
+ union all
722
+ select "table", "id", "column", "value"
723
+ from evolu_message_quarantine
724
+ where "ownerId" = ${ownerId} and "timestamp" = ${timestamp};
689
725
  `);
690
- if (!result.ok) return result;
691
- }
692
726
 
693
- if (message.restore) {
694
- const result = ensureDbSchema(deps)(message.restore.dbSchema);
695
- if (!result.ok) return result;
727
+ const { rows } = result;
728
+ assertNonEmptyReadonlyArray(rows, "Every timestamp must have rows");
729
+ const firstRow = firstInArray(rows);
730
+
731
+ const values = createRecord<string, SqliteValue>();
732
+ let isInsert: DbChange["isInsert"] = false;
733
+ let isDelete: DbChange["isDelete"] = null;
734
+
735
+ for (const r of rows) {
736
+ switch (r.column) {
737
+ case "createdAt":
738
+ isInsert = true;
739
+ break;
740
+ case "updatedAt":
741
+ isInsert = false;
742
+ break;
743
+ case "isDeleted":
744
+ assertType(SqliteBoolean, r.value);
745
+ isDelete = sqliteBooleanToBoolean(r.value);
746
+ break;
747
+ default:
748
+ values[r.column] = r.value;
749
+ }
750
+ }
751
+
752
+ const message: CrdtMessage = {
753
+ timestamp: timestampBytesToTimestamp(timestamp),
754
+ change: DbChange.orThrow({
755
+ table: firstRow.table,
756
+ id: idBytesToId(firstRow.id),
757
+ values,
758
+ isInsert,
759
+ isDelete,
760
+ }),
761
+ };
762
+
763
+ return encodeAndEncryptDbChange(deps)(message, getEncryptionKey());
764
+ },
765
+ };
766
+ };
767
+
768
+ const handleMutation =
769
+ (
770
+ deps: BaseSqliteStorageDep &
771
+ ClockDep &
772
+ SqliteSchemaDep &
773
+ RandomBytesDep &
774
+ SqliteDep &
775
+ TimeDep &
776
+ TimestampConfigDep,
777
+ ) =>
778
+ (
779
+ message: ExtractType<
780
+ ExtractType<DbWorkerInput["request"], "ForEvolu">["message"],
781
+ "Mutate"
782
+ >,
783
+ ): Result<
784
+ {
785
+ readonly type: "Mutate";
786
+ readonly messagesByOwnerId: ReadonlyMap<
787
+ OwnerId,
788
+ NonEmptyReadonlyArray<CrdtMessage>
789
+ >;
790
+ readonly rowsByQuery: RowsByQueryMap;
791
+ },
792
+ | TimestampDriftError
793
+ | TimestampCounterOverflowError
794
+ | TimestampTimeOutOfRangeError
795
+ > =>
796
+ deps.sqlite.transaction(() => {
797
+ const messagesByOwnerId = new Map<OwnerId, NonEmptyArray<CrdtMessage>>();
798
+ let clockTimestamp = deps.clock.get();
799
+ let clockChanged = false;
800
+
801
+ for (const change of message.changes) {
802
+ if (change.table.startsWith("_")) {
803
+ applyLocalOnlyChange(deps)(change);
804
+ continue;
805
+ }
806
+
807
+ const nextTimestamp = sendTimestamp(deps)(clockTimestamp);
808
+ if (!nextTimestamp.ok) return nextTimestamp;
809
+
810
+ clockTimestamp = nextTimestamp.value;
811
+ clockChanged = true;
812
+
813
+ const { ownerId, ...dbChange } = change;
814
+ const message: CrdtMessage = {
815
+ timestamp: clockTimestamp,
816
+ change: dbChange,
817
+ };
696
818
 
697
- const secret = mnemonicToOwnerSecret(message.restore.mnemonic);
698
- const appOwner = createAppOwner(secret);
699
- const clock = createClock(deps)();
819
+ const messages = messagesByOwnerId.get(ownerId);
820
+ if (messages) messages.push(message);
821
+ else messagesByOwnerId.set(ownerId, [message]);
822
+ }
700
823
 
701
- return initializeDb(deps)(appOwner, clock.get());
824
+ for (const [ownerId, messages] of messagesByOwnerId) {
825
+ applyMessages(deps)(ownerId, messages);
702
826
  }
703
827
 
704
- return ok();
828
+ if (clockChanged) deps.clock.save(clockTimestamp);
829
+
830
+ return ok({
831
+ type: "Mutate",
832
+ messagesByOwnerId,
833
+ rowsByQuery: loadQueries(deps)(message.subscribedQueries),
834
+ });
705
835
  });
706
836
 
707
- if (!result.ok) {
708
- deps.postMessage({ type: "onError", error: result.error });
709
- return;
837
+ const applyLocalOnlyChange =
838
+ (deps: SqliteDep & TimeDep) =>
839
+ (change: MutationChange): void => {
840
+ if (change.isDelete) {
841
+ deps.sqlite.exec(sql`
842
+ delete from ${sql.identifier(change.table)}
843
+ where "ownerId" = ${change.ownerId} and "id" = ${change.id};
844
+ `);
845
+ } else {
846
+ const ownerId = change.ownerId;
847
+ const columns = dbChangeToColumns(change, deps.time.now());
848
+
849
+ for (const [column, value] of columns) {
850
+ deps.sqlite.exec(sql.prepared`
851
+ insert into ${sql.identifier(change.table)}
852
+ ("ownerId", "id", ${sql.identifier(column)})
853
+ values (${ownerId}, ${change.id}, ${value})
854
+ on conflict ("ownerId", "id") do update
855
+ set ${sql.identifier(column)} = ${value};
856
+ `);
857
+ }
710
858
  }
859
+ };
711
860
 
712
- deps.postMessage({
713
- type: "onReset",
714
- onCompleteId: message.onCompleteId,
715
- reload: message.reload,
716
- });
717
- },
861
+ const applyMessages =
862
+ (deps: BaseSqliteStorageDep & ClockDep & SqliteSchemaDep & SqliteDep) =>
863
+ (ownerId: OwnerId, messages: NonEmptyReadonlyArray<CrdtMessage>): void => {
864
+ const ownerIdBytes = ownerIdToOwnerIdBytes(ownerId);
718
865
 
719
- ensureDbSchema: (deps) => (message) => {
720
- const result = deps.sqlite.transaction(() =>
721
- ensureDbSchema(deps)(message.dbSchema),
866
+ const usage = readOwnerUsageOrDefault(deps)(
867
+ ownerIdBytes,
868
+ timestampToTimestampBytes(firstInArray(messages).timestamp),
722
869
  );
723
870
 
724
- if (!result.ok) {
725
- deps.postMessage({ type: "onError", error: result.error });
726
- return;
727
- }
728
- },
871
+ let { firstTimestamp, lastTimestamp } = usage;
872
+
873
+ for (const { timestamp, change } of messages) {
874
+ const columns = dbChangeToColumns(change, timestamp.millis);
875
+ const idBytes = idToIdBytes(change.id);
876
+ const timestampBytes = timestampToTimestampBytes(timestamp);
877
+
878
+ for (const [column, value] of columns) {
879
+ if (validateColumnValue(deps)(change.table, column, value)) {
880
+ applyColumnChange(deps)(
881
+ ownerIdBytes,
882
+ ownerId,
883
+ change.table,
884
+ idBytes,
885
+ change.id,
886
+ column,
887
+ value,
888
+ timestampBytes,
889
+ );
890
+ } else {
891
+ deps.sqlite.exec(sql.prepared`
892
+ insert into evolu_message_quarantine
893
+ ("ownerId", "timestamp", "table", "id", "column", "value")
894
+ values
895
+ (
896
+ ${ownerIdBytes},
897
+ ${timestampBytes},
898
+ ${change.table},
899
+ ${idBytes},
900
+ ${column},
901
+ ${value}
902
+ )
903
+ on conflict do nothing;
904
+ `);
905
+ }
906
+ }
729
907
 
730
- export: (deps) => (message) => {
731
- const file = deps.sqlite.export();
908
+ let strategy;
909
+ [strategy, firstTimestamp, lastTimestamp] = getTimestampInsertStrategy(
910
+ timestampBytes,
911
+ firstTimestamp,
912
+ lastTimestamp,
913
+ );
732
914
 
733
- if (!file.ok) {
734
- deps.postMessage({ type: "onError", error: file.error });
735
- return;
915
+ deps.baseSqliteStorage.insertTimestamp(
916
+ ownerIdBytes,
917
+ timestampBytes,
918
+ strategy,
919
+ );
736
920
  }
737
921
 
738
- deps.postMessage({
739
- type: "onExport",
740
- onCompleteId: message.onCompleteId,
741
- file: file.value,
742
- });
743
- },
922
+ /**
923
+ * TODO: Implement proper storedBytes tracking for client using received and
924
+ * sent encrypted message sizes.
925
+ */
926
+ updateOwnerUsage(deps)(
927
+ ownerIdBytes,
928
+ onePositiveInt, // Placeholder until proper tracking implemented
929
+ firstTimestamp,
930
+ lastTimestamp,
931
+ );
932
+ };
744
933
 
745
- useOwner: (deps) => (message) => {
746
- deps.sync.useOwner(message.use, message.owner);
747
- },
934
+ const dbChangeToColumns = (change: DbChange, now: Millis) => {
935
+ let values = objectToEntries(change.values);
936
+
937
+ // SystemColumns are not encoded in change.values.
938
+ values = appendToArray(values, [
939
+ change.isInsert ? "createdAt" : "updatedAt",
940
+ millisToDateIso(now),
941
+ ]);
942
+ if (change.isDelete != null) {
943
+ values = appendToArray(values, [
944
+ "isDeleted",
945
+ booleanToSqliteBoolean(change.isDelete),
946
+ ]);
947
+ }
948
+
949
+ return values;
748
950
  };
951
+
952
+ const loadQueries =
953
+ (deps: SqliteDep) =>
954
+ (queries: Iterable<Query>): Map<Query, ReadonlyArray<SqliteRow>> => {
955
+ const rowsByQuery = new Map<Query, ReadonlyArray<SqliteRow>>();
956
+
957
+ for (const query of queries) {
958
+ const { rows } = deps.sqlite.exec(sqliteQueryStringToSqliteQuery(query));
959
+ rowsByQuery.set(query, rows);
960
+ }
961
+
962
+ return rowsByQuery;
963
+ };
964
+
965
+ // reset: (deps) => (message) => {
966
+ // const result = deps.sqlite.transaction(() => {
967
+ // const sqliteSchema = getSqliteSchema(deps)();
968
+ // if (!sqliteSchema.ok) return sqliteSchema;
969
+
970
+ // for (const tableName in sqliteSchema.value.tables) {
971
+ // /**
972
+ // * The dropped table is completely removed from the database schema and
973
+ // * the disk file. The table can not be recovered. All indices and
974
+ // * triggers associated with the table are also deleted.
975
+ // * https://sqlite.org/lang_droptable.html
976
+ // */
977
+ // const result = deps.sqlite.exec(sql`
978
+ // drop table ${sql.identifier(tableName)};
979
+ // `);
980
+ // if (!result.ok) return result;
981
+ // }
982
+
983
+ // if (message.restore) {
984
+ // const result = ensureSqliteSchema(deps)(message.restore.sqliteSchema);
985
+ // if (!result.ok) return result;
986
+
987
+ // const secret = mnemonicToOwnerSecret(message.restore.mnemonic);
988
+ // const appOwner = createAppOwner(secret);
989
+ // const clock = createClock(deps)();
990
+
991
+ // return initializeDb(deps)(appOwner, clock.get());
992
+ // }
993
+
994
+ // return ok();
995
+ // });
996
+
997
+ // if (!result.ok) {
998
+ // deps.postMessage({ type: "onError", error: result.error });
999
+ // return;
1000
+ // }
1001
+
1002
+ // deps.postMessage({
1003
+ // type: "onReset",
1004
+ // onCompleteId: message.onCompleteId,
1005
+ // reload: message.reload,
1006
+ // });
1007
+ // },
1008
+
1009
+ // ensureSqliteSchema: (deps) => (message) => {
1010
+ // const result = deps.sqlite.transaction(() =>
1011
+ // ensureSqliteSchema(deps)(message.sqliteSchema),
1012
+ // );
1013
+
1014
+ // if (!result.ok) {
1015
+ // deps.postMessage({ type: "onError", error: result.error });
1016
+ // return;
1017
+ // }
1018
+ // },