@evolu/common 6.0.1-preview.2 → 6.0.1-preview.21

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 (154) hide show
  1. package/dist/src/Assert.d.ts +6 -3
  2. package/dist/src/Assert.d.ts.map +1 -1
  3. package/dist/src/Assert.js +7 -4
  4. package/dist/src/Brand.d.ts +75 -0
  5. package/dist/src/Brand.d.ts.map +1 -0
  6. package/dist/src/Brand.js +1 -0
  7. package/dist/src/Buffer.d.ts +1 -1
  8. package/dist/src/Buffer.d.ts.map +1 -1
  9. package/dist/src/Buffer.js +1 -1
  10. package/dist/src/CallbackRegistry.d.ts +53 -0
  11. package/dist/src/CallbackRegistry.d.ts.map +1 -0
  12. package/dist/src/CallbackRegistry.js +25 -0
  13. package/dist/src/Console.d.ts +31 -6
  14. package/dist/src/Console.d.ts.map +1 -1
  15. package/dist/src/Console.js +72 -9
  16. package/dist/src/Crypto.d.ts +61 -34
  17. package/dist/src/Crypto.d.ts.map +1 -1
  18. package/dist/src/Crypto.js +29 -42
  19. package/dist/src/Evolu/Db.d.ts +159 -66
  20. package/dist/src/Evolu/Db.d.ts.map +1 -1
  21. package/dist/src/Evolu/Db.js +300 -705
  22. package/dist/src/Evolu/Diff.d.ts +3 -3
  23. package/dist/src/Evolu/Diff.d.ts.map +1 -1
  24. package/dist/src/Evolu/Diff.js +7 -5
  25. package/dist/src/Evolu/Evolu.d.ts +200 -115
  26. package/dist/src/Evolu/Evolu.d.ts.map +1 -1
  27. package/dist/src/Evolu/Evolu.js +287 -157
  28. package/dist/src/Evolu/Internal.d.ts +0 -2
  29. package/dist/src/Evolu/Internal.d.ts.map +1 -1
  30. package/dist/src/Evolu/Internal.js +0 -2
  31. package/dist/src/Evolu/LocalAuth.d.ts +144 -0
  32. package/dist/src/Evolu/LocalAuth.d.ts.map +1 -0
  33. package/dist/src/Evolu/LocalAuth.js +171 -0
  34. package/dist/src/Evolu/Owner.d.ts +209 -124
  35. package/dist/src/Evolu/Owner.d.ts.map +1 -1
  36. package/dist/src/Evolu/Owner.js +118 -104
  37. package/dist/src/Evolu/Platform.d.ts +9 -7
  38. package/dist/src/Evolu/Platform.d.ts.map +1 -1
  39. package/dist/src/Evolu/Protocol.d.ts +256 -211
  40. package/dist/src/Evolu/Protocol.d.ts.map +1 -1
  41. package/dist/src/Evolu/Protocol.js +558 -359
  42. package/dist/src/Evolu/Public.d.ts +6 -8
  43. package/dist/src/Evolu/Public.d.ts.map +1 -1
  44. package/dist/src/Evolu/Public.js +2 -3
  45. package/dist/src/Evolu/PublicKysely.js +3 -3
  46. package/dist/src/Evolu/Query.d.ts +2 -1
  47. package/dist/src/Evolu/Query.d.ts.map +1 -1
  48. package/dist/src/Evolu/Relay.d.ts +72 -4
  49. package/dist/src/Evolu/Relay.d.ts.map +1 -1
  50. package/dist/src/Evolu/Relay.js +124 -13
  51. package/dist/src/Evolu/Schema.d.ts +129 -73
  52. package/dist/src/Evolu/Schema.d.ts.map +1 -1
  53. package/dist/src/Evolu/Schema.js +169 -89
  54. package/dist/src/Evolu/Storage.d.ts +159 -14
  55. package/dist/src/Evolu/Storage.d.ts.map +1 -1
  56. package/dist/src/Evolu/Storage.js +42 -32
  57. package/dist/src/Evolu/Sync.d.ts +68 -13
  58. package/dist/src/Evolu/Sync.d.ts.map +1 -1
  59. package/dist/src/Evolu/Sync.js +414 -20
  60. package/dist/src/Evolu/Timestamp.d.ts +35 -27
  61. package/dist/src/Evolu/Timestamp.d.ts.map +1 -1
  62. package/dist/src/Evolu/Timestamp.js +27 -18
  63. package/dist/src/ManyToManyMap.d.ts +74 -10
  64. package/dist/src/ManyToManyMap.d.ts.map +1 -1
  65. package/dist/src/ManyToManyMap.js +41 -6
  66. package/dist/src/Number.d.ts +2 -1
  67. package/dist/src/Number.d.ts.map +1 -1
  68. package/dist/src/Random.d.ts +3 -2
  69. package/dist/src/Random.d.ts.map +1 -1
  70. package/dist/src/RefCountedResourceManager.d.ts +119 -0
  71. package/dist/src/RefCountedResourceManager.d.ts.map +1 -0
  72. package/dist/src/RefCountedResourceManager.js +197 -0
  73. package/dist/src/Result.d.ts +176 -51
  74. package/dist/src/Result.d.ts.map +1 -1
  75. package/dist/src/Result.js +30 -241
  76. package/dist/src/Sqlite.d.ts +21 -4
  77. package/dist/src/Sqlite.d.ts.map +1 -1
  78. package/dist/src/Sqlite.js +50 -8
  79. package/dist/src/Task.d.ts +511 -0
  80. package/dist/src/Task.d.ts.map +1 -0
  81. package/dist/src/Task.js +410 -0
  82. package/dist/src/Time.d.ts +59 -0
  83. package/dist/src/Time.d.ts.map +1 -1
  84. package/dist/src/Time.js +87 -4
  85. package/dist/src/Type.d.ts +474 -327
  86. package/dist/src/Type.d.ts.map +1 -1
  87. package/dist/src/Type.js +502 -436
  88. package/dist/src/Types.d.ts +0 -74
  89. package/dist/src/Types.d.ts.map +1 -1
  90. package/dist/src/WebSocket.d.ts +5 -2
  91. package/dist/src/WebSocket.d.ts.map +1 -1
  92. package/dist/src/WebSocket.js +12 -13
  93. package/dist/src/Worker.d.ts +39 -11
  94. package/dist/src/Worker.d.ts.map +1 -1
  95. package/dist/src/Worker.js +22 -4
  96. package/dist/src/index.d.ts +3 -3
  97. package/dist/src/index.d.ts.map +1 -1
  98. package/dist/src/index.js +3 -3
  99. package/package.json +14 -13
  100. package/src/Assert.ts +8 -7
  101. package/src/Brand.ts +75 -0
  102. package/src/Buffer.ts +1 -1
  103. package/src/CallbackRegistry.ts +84 -0
  104. package/src/Console.ts +91 -11
  105. package/src/Crypto.ts +94 -79
  106. package/src/Evolu/Db.ts +528 -1027
  107. package/src/Evolu/Diff.ts +7 -5
  108. package/src/Evolu/Evolu.ts +682 -329
  109. package/src/Evolu/Internal.ts +0 -2
  110. package/src/Evolu/LocalAuth.ts +422 -0
  111. package/src/Evolu/Owner.ts +282 -227
  112. package/src/Evolu/Platform.ts +9 -9
  113. package/src/Evolu/Protocol.ts +788 -640
  114. package/src/Evolu/Public.ts +7 -14
  115. package/src/Evolu/PublicKysely.ts +3 -3
  116. package/src/Evolu/Query.ts +2 -1
  117. package/src/Evolu/Relay.ts +238 -18
  118. package/src/Evolu/Schema.ts +391 -191
  119. package/src/Evolu/Storage.ts +275 -55
  120. package/src/Evolu/Sync.ts +708 -37
  121. package/src/Evolu/Timestamp.ts +38 -35
  122. package/src/ManyToManyMap.ts +127 -24
  123. package/src/Number.ts +2 -6
  124. package/src/Random.ts +3 -2
  125. package/src/RefCountedResourceManager.ts +368 -0
  126. package/src/Result.ts +183 -53
  127. package/src/Sqlite.ts +59 -23
  128. package/src/Task.ts +779 -0
  129. package/src/Time.ts +168 -4
  130. package/src/Type.ts +708 -660
  131. package/src/Types.ts +0 -76
  132. package/src/WebSocket.ts +23 -17
  133. package/src/Worker.ts +72 -23
  134. package/src/index.ts +3 -3
  135. package/dist/src/Callbacks.d.ts +0 -20
  136. package/dist/src/Callbacks.d.ts.map +0 -1
  137. package/dist/src/Callbacks.js +0 -18
  138. package/dist/src/Evolu/Config.d.ts +0 -69
  139. package/dist/src/Evolu/Config.d.ts.map +0 -1
  140. package/dist/src/Evolu/Config.js +0 -9
  141. package/dist/src/Evolu/Kysely.d.ts +0 -6
  142. package/dist/src/Evolu/Kysely.d.ts.map +0 -1
  143. package/dist/src/Evolu/Kysely.js +0 -21
  144. package/dist/src/NanoId.d.ts +0 -27
  145. package/dist/src/NanoId.d.ts.map +0 -1
  146. package/dist/src/NanoId.js +0 -6
  147. package/dist/src/Promise.d.ts +0 -180
  148. package/dist/src/Promise.d.ts.map +0 -1
  149. package/dist/src/Promise.js +0 -176
  150. package/src/Callbacks.ts +0 -43
  151. package/src/Evolu/Config.ts +0 -83
  152. package/src/Evolu/Kysely.ts +0 -38
  153. package/src/NanoId.ts +0 -39
  154. package/src/Promise.ts +0 -295
package/src/Evolu/Db.ts CHANGED
@@ -1,72 +1,44 @@
1
+ import { isNonEmptyArray, NonEmptyReadonlyArray } from "../Array.js";
2
+ import { CallbackId } from "../CallbackRegistry.js";
3
+ import { ConsoleConfig, ConsoleDep } from "../Console.js";
1
4
  import {
2
- isNonEmptyArray,
3
- isNonEmptyReadonlyArray,
4
- NonEmptyReadonlyArray,
5
- } from "../Array.js";
6
- import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
7
- import { CallbackId } from "../Callbacks.js";
8
- import { ConsoleDep } from "../Console.js";
9
- import {
10
- CreateMnemonicDep,
11
- CreateRandomBytesDep,
12
5
  createSymmetricCrypto,
6
+ EncryptionKey,
7
+ RandomBytesDep,
13
8
  SymmetricCryptoDecryptError,
14
- SymmetricCryptoDep,
15
9
  } from "../Crypto.js";
16
- import { eqArrayNumber } from "../Eq.js";
17
10
  import { TransferableError } from "../Error.js";
18
- import { constFalse, exhaustiveCheck } from "../Function.js";
19
- import { NanoIdLibDep } from "../NanoId.js";
20
- import { objectToEntries } from "../Object.js";
21
11
  import { RandomDep } from "../Random.js";
22
- import { createRef, Ref } from "../Ref.js";
23
- import { err, ok, Result } from "../Result.js";
12
+ import { ok, Result } from "../Result.js";
24
13
  import {
25
14
  createSqlite,
26
15
  CreateSqliteDriverDep,
27
16
  explainSqliteQueryPlan,
28
- SafeSql,
29
17
  sql,
30
18
  SqliteDep,
31
19
  SqliteError,
32
- SqliteQuery,
33
- SqliteRow,
34
- SqliteValue,
35
20
  } from "../Sqlite.js";
36
21
  import { TimeDep } from "../Time.js";
37
- import { Id, Mnemonic, object, SimpleName, String } from "../Type.js";
22
+ import { Id, Mnemonic, SimpleName } from "../Type.js";
23
+ import { CreateWebSocketDep } from "../WebSocket.js";
38
24
  import {
39
- createInitializedWorker,
25
+ createInitializedWorkerWithHandlers,
26
+ MessageHandlers,
40
27
  Worker,
41
- WorkerPostMessageDep,
42
28
  } from "../Worker.js";
43
- import { Config } from "./Config.js";
44
29
  import { makePatches, QueryPatches } from "./Diff.js";
45
30
  import {
46
31
  AppOwner,
47
32
  createAppOwner,
48
- createOwnerRow,
49
- OwnerRow,
50
- OwnerWithWriteAccess,
33
+ createOwnerSecret,
34
+ createWebSocketTransportConfig,
35
+ mnemonicToOwnerSecret,
36
+ OwnerEncryptionKey,
37
+ OwnerId,
38
+ OwnerWriteKey,
39
+ TransportConfig,
51
40
  } from "./Owner.js";
52
- import {
53
- applyProtocolMessageAsClient,
54
- Base64Url256,
55
- BinaryId,
56
- binaryIdToId,
57
- CrdtMessage,
58
- createProtocolMessageForSync,
59
- createProtocolMessageFromCrdtMessages,
60
- DbChange,
61
- decryptAndDecodeDbChange,
62
- encodeAndEncryptDbChange,
63
- idToBinaryId,
64
- ownerIdToBinaryOwnerId,
65
- ProtocolError,
66
- protocolVersion,
67
- Storage,
68
- StorageDep,
69
- } from "./Protocol.js";
41
+ import { ProtocolError, protocolVersion } from "./Protocol.js";
70
42
  import {
71
43
  createQueryRowsCache,
72
44
  deserializeQuery,
@@ -75,39 +47,167 @@ import {
75
47
  QueryRowsCache,
76
48
  } from "./Query.js";
77
49
  import {
78
- createSqliteStorageBase,
79
- CreateSqliteStorageBaseOptions,
80
- SqliteStorageBase,
81
- } from "./Storage.js";
82
- import { CreateSyncDep, SyncConfig, SyncDep } from "./Sync.js";
50
+ DbSchema,
51
+ ensureDbSchema,
52
+ getDbSchema,
53
+ MutationChange,
54
+ } from "./Schema.js";
55
+ import {
56
+ applyLocalOnlyChange,
57
+ Clock,
58
+ createClock,
59
+ createSync,
60
+ SyncDep,
61
+ SyncOwner,
62
+ } from "./Sync.js";
83
63
  import {
84
- Millis,
85
- receiveTimestamp,
86
- sendTimestamp,
87
64
  Timestamp,
88
- TimestampConfigDep,
89
- TimestampCounterOverflowError,
90
- TimestampDriftError,
65
+ TimestampConfig,
91
66
  TimestampError,
92
67
  TimestampString,
93
68
  timestampStringToTimestamp,
94
- TimestampTimeOutOfRangeError,
95
- timestampToBinaryTimestamp,
96
69
  timestampToTimestampString,
97
70
  } from "./Timestamp.js";
98
71
 
99
- export interface DbSchema {
100
- readonly tables: ReadonlyArray<DbTable>;
101
- readonly indexes: ReadonlyArray<DbIndex>;
102
- }
103
-
104
- export interface DbTable {
105
- readonly name: Base64Url256;
106
- readonly columns: ReadonlyArray<Base64Url256>;
72
+ export interface DbConfig extends ConsoleConfig, TimestampConfig {
73
+ /**
74
+ * The name of the Evolu instance. Evolu is multitenant - it can run multiple
75
+ * instances concurrently. Each instance must have a unique name.
76
+ *
77
+ * The instance name is used as the SQLite database filename for persistent
78
+ * storage, ensuring that database files are separated and invisible to each
79
+ * other.
80
+ *
81
+ * The default value is: `Evolu`.
82
+ *
83
+ * ### Example
84
+ *
85
+ * ```ts
86
+ * // name: SimpleName.orThrow("MyApp")
87
+ * ```
88
+ */
89
+ readonly name: SimpleName;
90
+
91
+ /**
92
+ * Transport configuration for data sync and backup. Supports single transport
93
+ * or multiple transports simultaneously for redundancy.
94
+ *
95
+ * **Redundancy:** The ideal setup uses at least two completely independent
96
+ * relays - for example, a home relay and a geographically separate relay.
97
+ * Data is sent to both relays simultaneously, providing true redundancy
98
+ * similar to using two independent clouds. This eliminates vendor lock-in and
99
+ * ensures your app continues working regardless of circumstances - whether
100
+ * home relay hardware is stolen or a remote relay provider shuts down.
101
+ *
102
+ * Currently supports:
103
+ *
104
+ * - WebSocket: Real-time bidirectional communication with relay servers
105
+ *
106
+ * Empty transports create local-only instances. Transports can be dynamically
107
+ * added and removed for any owner (including {@link AppOwner}) via
108
+ * {@link Evolu#useOwner}.
109
+ *
110
+ * Use {@link createWebSocketTransportConfig} to create WebSocket transport
111
+ * configurations with proper URL formatting and {@link OwnerId} inclusion. The
112
+ * {@link OwnerId} in the URL enables relay authentication, allowing relay
113
+ * servers to control access (e.g., for paid tiers or private instances).
114
+ *
115
+ * The default value is:
116
+ *
117
+ * `{ type: "WebSocket", url: "wss://free.evoluhq.com" }`.
118
+ *
119
+ * ### Example
120
+ *
121
+ * ```ts
122
+ * // Single WebSocket relay
123
+ * transports: [{ type: "WebSocket", url: "wss://relay1.example.com" }];
124
+ *
125
+ * // Multiple WebSocket relays for redundancy
126
+ * transports: [
127
+ * { type: "WebSocket", url: "wss://relay1.example.com" },
128
+ * { type: "WebSocket", url: "wss://relay2.example.com" },
129
+ * { type: "WebSocket", url: "wss://relay3.example.com" },
130
+ * ];
131
+ *
132
+ * // Local-only instance (no sync) - useful for device settings or when relay
133
+ * // URL will be provided later (e.g., after authentication), allowing users
134
+ * // to work offline before the app connects
135
+ * transports: [];
136
+ *
137
+ * // Using createWebSocketTransportConfig helper for relay authentication
138
+ * transports: [
139
+ * createWebSocketTransportConfig({
140
+ * relayUrl: "ws://localhost:4000",
141
+ * ownerId,
142
+ * }),
143
+ * ];
144
+ * ```
145
+ */
146
+ readonly transports: ReadonlyArray<TransportConfig>;
147
+
148
+ /**
149
+ * External AppOwner to use when creating Evolu instance. Use this when you
150
+ * want to manage AppOwner creation and persistence externally (e.g., with
151
+ * your own authentication system). If omitted, Evolu will automatically
152
+ * create and persist an AppOwner locally.
153
+ *
154
+ * For device-specific settings and account management state, we can use a
155
+ * separate local-only Evolu instance via `transports: []`.
156
+ *
157
+ * ### Example
158
+ *
159
+ * ```ts
160
+ * const ConfigId = id("Config");
161
+ * type ConfigId = typeof ConfigId.Type;
162
+ *
163
+ * const DeviceSchema = {
164
+ * config: {
165
+ * id: ConfigId,
166
+ * key: NonEmptyString50,
167
+ * value: NonEmptyString50,
168
+ * },
169
+ * };
170
+ *
171
+ * // Local-only instance for device settings (no sync)
172
+ * const deviceEvolu = createEvolu(evoluReactWebDeps)(DeviceSchema, {
173
+ * name: SimpleName.orThrow("MyApp-Device"),
174
+ * transports: [], // No sync - stays local to device
175
+ * });
176
+ *
177
+ * // Main synced instance for user data
178
+ * const evolu = createEvolu(evoluReactWebDeps)(MainSchema, {
179
+ * name: SimpleName.orThrow("MyApp"),
180
+ * // Default transports for sync
181
+ * });
182
+ * ```
183
+ */
184
+ readonly externalAppOwner?: AppOwner;
185
+
186
+ /**
187
+ * Use in-memory SQLite database instead of persistent storage. Useful for
188
+ * testing or temporary data that doesn't need persistence.
189
+ *
190
+ * In-memory databases exist only in RAM and are completely destroyed when the
191
+ * process ends, making them forensically safe for sensitive data.
192
+ *
193
+ * The default value is: `false`.
194
+ */
195
+ readonly inMemory?: boolean;
196
+
197
+ /**
198
+ * Encryption key for the SQLite database.
199
+ *
200
+ * @experimental
201
+ */
202
+ readonly encryptionKey?: EncryptionKey;
107
203
  }
108
204
 
109
- export const DbIndex = object({ name: String, sql: String });
110
- export type DbIndex = typeof DbIndex.Type;
205
+ export const defaultDbConfig: DbConfig = {
206
+ name: SimpleName.orThrow("Evolu"),
207
+ transports: [{ type: "WebSocket", url: "wss://free.evoluhq.com" }],
208
+ maxDrift: 5 * 60 * 1000,
209
+ enableLogging: false,
210
+ };
111
211
 
112
212
  export type DbWorker = Worker<DbWorkerInput, DbWorkerOutput>;
113
213
 
@@ -120,14 +220,16 @@ export interface CreateDbWorkerDep {
120
220
  export type DbWorkerInput =
121
221
  | {
122
222
  readonly type: "init";
123
- readonly config: Config;
223
+ readonly config: DbConfig;
124
224
  readonly dbSchema: DbSchema;
125
- readonly initialData: ReadonlyArray<DbChange>;
225
+ }
226
+ | {
227
+ readonly type: "getAppOwner";
126
228
  }
127
229
  | {
128
230
  readonly type: "mutate";
129
231
  readonly tabId: Id;
130
- readonly changes: NonEmptyReadonlyArray<DbChange>;
232
+ readonly changes: NonEmptyReadonlyArray<MutationChange>;
131
233
  readonly onCompleteIds: ReadonlyArray<CallbackId>;
132
234
  readonly subscribedQueries: ReadonlyArray<Query>;
133
235
  }
@@ -152,30 +254,35 @@ export type DbWorkerInput =
152
254
  | {
153
255
  readonly type: "export";
154
256
  readonly onCompleteId: CallbackId;
257
+ }
258
+ | {
259
+ readonly type: "useOwner";
260
+ readonly use: boolean;
261
+ readonly owner: SyncOwner;
155
262
  };
156
263
 
157
264
  export type DbWorkerOutput =
158
- | {
159
- readonly type: "onInit";
160
- readonly owner: AppOwner;
161
- }
162
265
  | {
163
266
  readonly type: "onError";
164
267
  readonly error:
268
+ | ProtocolError
165
269
  | SqliteError
166
- | TransferableError
270
+ | SymmetricCryptoDecryptError
167
271
  | TimestampError
168
- | ProtocolError
169
- | SymmetricCryptoDecryptError;
272
+ | TransferableError;
273
+ }
274
+ | {
275
+ readonly type: "onGetAppOwner";
276
+ readonly appOwner: AppOwner;
170
277
  }
171
278
  | {
172
- readonly type: "onChange";
279
+ readonly type: "onQueryPatches";
173
280
  readonly tabId: Id;
174
- readonly patches: ReadonlyArray<QueryPatches>;
281
+ readonly queryPatches: ReadonlyArray<QueryPatches>;
175
282
  readonly onCompleteIds: ReadonlyArray<CallbackId>;
176
283
  }
177
284
  | {
178
- readonly type: "onReceive";
285
+ readonly type: "refreshQueries";
179
286
  readonly tabId?: Id;
180
287
  }
181
288
  | {
@@ -189,812 +296,446 @@ export type DbWorkerOutput =
189
296
  readonly file: Uint8Array;
190
297
  };
191
298
 
192
- export type DbWorkerPlatformDeps = CreateSqliteDriverDep &
193
- CreateSyncDep &
194
- ConsoleDep &
195
- TimeDep &
299
+ export type DbWorkerPlatformDeps = ConsoleDep &
300
+ CreateSqliteDriverDep &
301
+ CreateWebSocketDep &
302
+ RandomBytesDep &
196
303
  RandomDep &
197
- NanoIdLibDep &
198
- CreateMnemonicDep &
199
- CreateRandomBytesDep;
304
+ TimeDep;
200
305
 
201
306
  type DbWorkerDeps = Omit<
202
307
  DbWorkerPlatformDeps,
203
- keyof CreateSqliteDriverDep | keyof CreateSyncDep
308
+ keyof CreateSqliteDriverDep | keyof CreateWebSocketDep
204
309
  > &
310
+ GetQueryRowsCacheDep &
311
+ PostMessageDep &
205
312
  SqliteDep &
206
313
  SyncDep &
207
- TimestampConfigDep &
208
- SymmetricCryptoDep &
209
- PostMessageDep &
210
- OwnerRowRefDep &
211
- GetQueryRowsCacheDep &
212
- ClientStorageDep;
314
+ AppOwnerDep;
213
315
 
214
- type PostMessageDep = WorkerPostMessageDep<DbWorkerOutput>;
316
+ interface GetQueryRowsCacheDep {
317
+ readonly getQueryRowsCache: (tabId: Id) => QueryRowsCache;
318
+ }
215
319
 
216
- // TODO: More owners (the whole table with ad-hoc added)
217
- export interface OwnerRowRefDep {
218
- readonly ownerRowRef: Ref<OwnerRow>;
320
+ export interface PostMessageDep {
321
+ readonly postMessage: (message: DbWorkerOutput) => void;
219
322
  }
220
323
 
221
- interface GetQueryRowsCacheDep {
222
- readonly getQueryRowsCache: (tabId: Id) => QueryRowsCache;
324
+ export interface AppOwnerDep {
325
+ readonly appOwner: AppOwner;
223
326
  }
224
327
 
225
328
  export const createDbWorkerForPlatform = (
226
329
  platformDeps: DbWorkerPlatformDeps,
227
- ): DbWorker => {
228
- const tabQueryRowsCacheMap = new Map<Id, QueryRowsCache>();
229
- const getQueryRowsCache = (tabId: Id) => {
230
- let cache = tabQueryRowsCacheMap.get(tabId);
231
- if (!cache) {
232
- cache = createQueryRowsCache();
233
- tabQueryRowsCacheMap.set(tabId, cache);
330
+ ): DbWorker =>
331
+ createInitializedWorkerWithHandlers<
332
+ DbWorkerInput,
333
+ DbWorkerOutput,
334
+ DbWorkerDeps
335
+ >({ init: createDbWorkerDeps(platformDeps), handlers });
336
+
337
+ const createDbWorkerDeps =
338
+ (platformDeps: DbWorkerPlatformDeps) =>
339
+ async (
340
+ initMessage: Extract<DbWorkerInput, { type: "init" }>,
341
+ postMessage: (msg: DbWorkerOutput) => void,
342
+ ): Promise<DbWorkerDeps | null> => {
343
+ platformDeps.console.enabled = initMessage.config.enableLogging ?? false;
344
+
345
+ const sqliteResult = await createSqlite(platformDeps)(
346
+ initMessage.config.name,
347
+ {
348
+ memory: initMessage.config.inMemory ?? false,
349
+ encryptionKey: initMessage.config.encryptionKey ?? undefined,
350
+ },
351
+ );
352
+ if (!sqliteResult.ok) {
353
+ postMessage({ type: "onError", error: sqliteResult.error });
354
+ return null;
234
355
  }
235
- return cache;
236
- };
237
-
238
- return createInitializedWorker<DbWorkerInput, DbWorkerOutput, DbWorkerDeps>({
239
- init: async (initMessage, postMessage, safeHandler) => {
240
- platformDeps.console.enabled = initMessage.config.enableLogging ?? false;
356
+ const sqlite = sqliteResult.value;
241
357
 
242
- const sqliteResult = await createSqlite(platformDeps)(
243
- initMessage.config.name,
244
- );
245
-
246
- if (!sqliteResult.ok) {
247
- postMessage({ type: "onError", error: sqliteResult.error });
248
- return null;
249
- }
250
- const sqlite = sqliteResult.value;
358
+ const deps = sqlite.transaction(() => {
359
+ const currentDbSchema = getDbSchema({ sqlite })();
360
+ if (!currentDbSchema.ok) return currentDbSchema;
251
361
 
252
- const deps = sqlite.transaction(() => {
253
- const currentDbSchema = getDbSchema({ sqlite })();
254
- if (!currentDbSchema.ok) return currentDbSchema;
362
+ let appOwner: AppOwner;
363
+ let clock: Clock;
255
364
 
256
- const ensureDbSchemaResult = ensureDbSchema({ sqlite })(
257
- initMessage.dbSchema,
258
- currentDbSchema.value,
259
- );
260
- if (!ensureDbSchemaResult.ok) return ensureDbSchemaResult;
261
-
262
- const maybeMigrateToVersion0Result = maybeMigrateToVersion0({ sqlite })(
263
- currentDbSchema.value,
264
- );
265
- if (!maybeMigrateToVersion0Result.ok)
266
- return maybeMigrateToVersion0Result;
267
-
268
- const ownerExists = currentDbSchema.value.tables.some(
269
- (table) => table.name === "evolu_owner",
270
- );
365
+ const dbIsInitialized = currentDbSchema.value.tables.some(
366
+ (table) => table.name === "evolu_version",
367
+ );
271
368
 
272
- const appOwnerAndOwnerRow = ownerExists
273
- ? selectAppOwner({ sqlite })
274
- : initializeDb({ ...platformDeps, sqlite })(
275
- maybeMigrateToVersion0Result.value?.mnemonic ??
276
- initMessage.config.mnemonic,
277
- );
278
- if (!appOwnerAndOwnerRow.ok) return appOwnerAndOwnerRow;
279
-
280
- const [appOwner, ownerRow] = appOwnerAndOwnerRow.value;
281
-
282
- const depsWithoutSyncAndStorage = {
283
- ...platformDeps,
284
- postMessage,
285
- sqlite,
286
- timestampConfig: initMessage.config,
287
- symmetricCrypto: createSymmetricCrypto(platformDeps),
288
- getQueryRowsCache,
289
- ownerRowRef: createRef(ownerRow),
290
- };
369
+ if (dbIsInitialized) {
370
+ const currentVersion = sqlite.exec<{
371
+ protocolVersion: number;
372
+ }>(sql`select protocolVersion from evolu_version limit 1;`);
373
+ if (!currentVersion.ok) return currentVersion;
374
+
375
+ // TODO: Handle version migrations here if needed
376
+ // const [{ protocolVersion }] = protocolVersionResult.value.rows;
377
+ // if (protocolVersion < currentProtocolVersion) {
378
+ // const migrateResult = migrateDatabase({ sqlite })(
379
+ // protocolVersion,
380
+ // currentProtocolVersion
381
+ // );
382
+ // if (!migrateResult.ok) return migrateResult;
383
+ // }
384
+
385
+ const configResult = sqlite.exec<{
386
+ clock: TimestampString;
387
+ appOwnerId: OwnerId;
388
+ appOwnerEncryptionKey: OwnerEncryptionKey;
389
+ appOwnerWriteKey: OwnerWriteKey;
390
+ appOwnerMnemonic: Mnemonic | null;
391
+ }>(sql`
392
+ select
393
+ clock,
394
+ appOwnerId,
395
+ appOwnerEncryptionKey,
396
+ appOwnerWriteKey,
397
+ appOwnerMnemonic
398
+ from evolu_config
399
+ limit 1;
400
+ `);
401
+ if (!configResult.ok) return configResult;
291
402
 
292
- const storage = createClientStorage(depsWithoutSyncAndStorage)({
293
- onStorageError: (error) => {
294
- postMessage({ type: "onError", error });
295
- },
296
- });
297
- if (!storage.ok) return storage;
403
+ const [config] = configResult.value.rows;
298
404
 
299
- const depsWithoutSync = {
300
- ...depsWithoutSyncAndStorage,
301
- storage: storage.value,
405
+ appOwner = {
406
+ type: "AppOwner",
407
+ id: config.appOwnerId,
408
+ encryptionKey: config.appOwnerEncryptionKey,
409
+ writeKey: config.appOwnerWriteKey,
410
+ mnemonic: config.appOwnerMnemonic,
302
411
  };
303
412
 
304
- if (maybeMigrateToVersion0Result.value) {
305
- const result = applyMessages(depsWithoutSync)(
306
- maybeMigrateToVersion0Result.value.messages,
307
- maybeMigrateToVersion0Result.value.lastTimestamp,
308
- );
309
- if (!result.ok) return result;
310
- }
311
-
312
- if (!ownerExists && isNonEmptyReadonlyArray(initMessage.initialData)) {
313
- const result = applyChanges(depsWithoutSync)(initMessage.initialData);
314
- if (!result.ok) return result;
315
- }
316
-
317
- postMessage({ type: "onInit", owner: appOwner });
318
-
319
- const sync = platformDeps.createSync(platformDeps)({
320
- ...initMessage.config,
321
- onOpen: safeHandler(handleSyncOpen(depsWithoutSync)),
322
- onMessage: safeHandler(createHandleSyncMessage(depsWithoutSync)),
323
- });
413
+ clock = createClock({ ...platformDeps, sqlite })(
414
+ timestampStringToTimestamp(config.clock),
415
+ );
416
+ } else {
417
+ appOwner =
418
+ initMessage.config.externalAppOwner ??
419
+ createAppOwner(createOwnerSecret(platformDeps));
324
420
 
325
- return ok({ ...depsWithoutSync, sync });
326
- });
421
+ clock = createClock({ ...platformDeps, sqlite })();
327
422
 
328
- if (!deps.ok) {
329
- postMessage({ type: "onError", error: deps.error });
330
- return null;
423
+ const result = initializeDb({ sqlite })(appOwner, clock.get());
424
+ if (!result.ok) return result;
331
425
  }
332
426
 
333
- return deps.value;
334
- },
335
-
336
- onMessage: (deps) => (message) => {
337
- switch (message.type) {
338
- case "mutate": {
339
- const mutate = deps.sqlite.transaction(() => {
340
- const toSyncChanges = [];
341
- const localOnlyChanges = [];
342
-
343
- for (const change of message.changes) {
344
- // Table name starting with '_' is local-only (not synced).
345
- if (change.table.startsWith("_")) localOnlyChanges.push(change);
346
- else toSyncChanges.push(change);
347
- }
348
-
349
- for (const change of localOnlyChanges) {
350
- if (
351
- "isDeleted" in change.values &&
352
- change.values.isDeleted === 1
353
- ) {
354
- const result = deps.sqlite.exec(sql`
355
- delete from ${sql.identifier(change.table)}
356
- where id = ${change.id};
357
- `);
358
- if (!result.ok) return result;
359
- } else {
360
- const millis = Millis.from(deps.time.now());
361
- if (!millis.ok) {
362
- return err<TimestampTimeOutOfRangeError>({
363
- type: "TimestampTimeOutOfRangeError",
364
- });
365
- }
366
- const date = new Date(millis.value).toISOString();
367
- for (const [column, value] of objectToEntries(change.values)) {
368
- const result = deps.sqlite.exec(sql.prepared`
369
- insert into ${sql.identifier(change.table)}
370
- ("id", ${sql.identifier(column)}, createdAt, updatedAt)
371
- values (${change.id}, ${value}, ${date}, ${date})
372
- on conflict ("id") do update
373
- set
374
- ${sql.identifier(column)} = ${value},
375
- updatedAt = ${date};
376
- `);
377
- if (!result.ok) return result;
378
- }
379
- }
380
- }
381
-
382
- /**
383
- * We don't have to wait for the transaction to end because changes
384
- * are idempotent, so there is no reason why they should fail. We
385
- * want to call onChange ASAP.
386
- */
387
- const onChange = () => {
388
- deps.console.log("[db]", "onChange", {
389
- subscribedQueries: message.subscribedQueries,
390
- });
391
- const patches = loadQueries(deps)(
392
- message.tabId,
393
- message.subscribedQueries,
394
- );
395
- if (!patches.ok) {
396
- deps.postMessage({ type: "onError", error: patches.error });
397
- return;
398
- }
399
- // Notify the tab that performed the mutation.
400
- deps.postMessage({
401
- type: "onChange",
402
- tabId: message.tabId,
403
- patches: patches.value,
404
- onCompleteIds: message.onCompleteIds,
405
- });
406
- // Notify other tabs to refresh their queries.
407
- deps.postMessage({ type: "onReceive", tabId: message.tabId });
408
- };
409
-
410
- if (!isNonEmptyArray(toSyncChanges)) {
411
- onChange();
412
- return ok();
413
- }
414
-
415
- const messages = applyChanges(deps)(toSyncChanges, onChange);
416
- if (!messages.ok) return messages;
417
-
418
- const owner = deps.ownerRowRef.get();
419
- // TODO: Check owner whether it's allowed to write, return an
420
- // error if not.
421
- if (owner.writeKey == null) {
422
- return ok();
423
- }
424
-
425
- const protocolMessage = createProtocolMessageFromCrdtMessages(deps)(
426
- owner as OwnerWithWriteAccess,
427
- messages.value,
428
- );
429
-
430
- deps.console.log(
431
- "[db]",
432
- "send data message",
433
- messages.value,
434
- protocolMessage,
435
- );
436
- deps.sync.send(protocolMessage);
437
-
438
- return ok();
439
- });
440
-
441
- if (!mutate.ok) {
442
- deps.postMessage({ type: "onError", error: mutate.error });
443
- return;
444
- }
445
-
446
- break;
447
- }
448
-
449
- case "query": {
450
- const patches = loadQueries(deps)(message.tabId, message.queries);
451
-
452
- if (!patches.ok) {
453
- deps.postMessage({ type: "onError", error: patches.error });
454
- return;
455
- }
456
-
457
- deps.postMessage({
458
- type: "onChange",
459
- tabId: message.tabId,
460
- patches: patches.value,
461
- onCompleteIds: [],
462
- });
463
- break;
464
- }
465
-
466
- case "reset": {
467
- const resetResult = deps.sqlite.transaction(() => {
468
- const dropAllTablesResult = dropAllTables(deps);
469
- if (!dropAllTablesResult.ok) return dropAllTablesResult;
470
-
471
- if (message.restore) {
472
- const dbSchema = getDbSchema(deps)();
473
- if (!dbSchema.ok) return dbSchema;
474
-
475
- const ensureDbSchemaResult = ensureDbSchema(deps)(
476
- message.restore.dbSchema,
477
- dbSchema.value,
478
- );
479
- if (!ensureDbSchemaResult.ok) return ensureDbSchemaResult;
480
-
481
- const initializeDbResult = initializeDb(deps)(
482
- message.restore.mnemonic,
483
- );
484
- if (!initializeDbResult.ok) return initializeDbResult;
485
- }
486
- return ok();
487
- });
488
-
489
- if (!resetResult.ok) {
490
- deps.postMessage({ type: "onError", error: resetResult.error });
491
- return;
492
- }
493
-
494
- deps.postMessage({
495
- type: "onReset",
496
- onCompleteId: message.onCompleteId,
497
- reload: message.reload,
498
- });
499
- deps.sqlite[Symbol.dispose]();
500
-
501
- break;
502
- }
503
-
504
- case "ensureDbSchema": {
505
- const ensureSchema = deps.sqlite.transaction(() => {
506
- const dbSchema = getDbSchema(deps)();
507
- if (!dbSchema.ok) return dbSchema;
508
-
509
- const ensureDbSchemaResult = ensureDbSchema(deps)(
510
- message.dbSchema,
511
- dbSchema.value,
512
- );
513
- if (!ensureDbSchemaResult.ok) return ensureDbSchemaResult;
514
-
515
- return ok();
516
- });
517
-
518
- if (!ensureSchema.ok) {
519
- deps.postMessage({ type: "onError", error: ensureSchema.error });
520
- return;
521
- }
522
- break;
523
- }
427
+ const result = ensureDbSchema({ sqlite })(
428
+ initMessage.dbSchema,
429
+ currentDbSchema.value,
430
+ );
431
+ if (!result.ok) return result;
524
432
 
525
- case "export": {
526
- const file = deps.sqlite.export();
433
+ const sync = createSync({
434
+ ...platformDeps,
435
+ clock,
436
+ sqlite,
437
+ symmetricCrypto: createSymmetricCrypto(platformDeps),
438
+ timestampConfig: initMessage.config,
439
+ postMessage,
440
+ })({
441
+ appOwner,
442
+ transports: initMessage.config.transports,
443
+ onError: (error) => {
444
+ postMessage({ type: "onError", error });
445
+ },
446
+ onReceive: () => {
447
+ postMessage({ type: "refreshQueries" });
448
+ },
449
+ });
450
+ if (!sync.ok) return sync;
527
451
 
528
- if (!file.ok) {
529
- deps.postMessage({ type: "onError", error: file.error });
530
- return;
531
- }
452
+ sync.value.useOwner(true, appOwner);
532
453
 
533
- deps.postMessage({
534
- type: "onExport",
535
- onCompleteId: message.onCompleteId,
536
- file: file.value,
537
- });
538
- break;
454
+ const tabQueryRowsCacheMap = new Map<Id, QueryRowsCache>();
455
+ const getQueryRowsCache = (tabId: Id) => {
456
+ let cache = tabQueryRowsCacheMap.get(tabId);
457
+ if (!cache) {
458
+ cache = createQueryRowsCache();
459
+ tabQueryRowsCacheMap.set(tabId, cache);
539
460
  }
540
-
541
- default:
542
- exhaustiveCheck(message);
543
- }
544
- },
545
- });
546
- };
547
-
548
- /**
549
- * Get the current database schema by reading SQLite metadata.
550
- *
551
- * TODO: Refactor out Evolu stuff and move it to Sqlite.
552
- */
553
- export const getDbSchema =
554
- (deps: SqliteDep) =>
555
- ({ allIndexes = false }: { allIndexes?: boolean } = {}): Result<
556
- DbSchema,
557
- SqliteError
558
- > => {
559
- const map = new Map<Base64Url256, Array<Base64Url256>>();
560
-
561
- const tableAndColumnInfoRows = deps.sqlite.exec(sql`
562
- select
563
- sqlite_master.name as tableName,
564
- table_info.name as columnName
565
- from
566
- sqlite_master
567
- join pragma_table_info(sqlite_master.name) as table_info;
568
- `);
569
-
570
- if (!tableAndColumnInfoRows.ok) return tableAndColumnInfoRows;
571
-
572
- tableAndColumnInfoRows.value.rows.forEach((row) => {
573
- const { tableName, columnName } = row as unknown as {
574
- tableName: Base64Url256;
575
- columnName: Base64Url256;
461
+ return cache;
576
462
  };
577
- if (!map.has(tableName)) map.set(tableName, []);
578
- map.get(tableName)?.push(columnName);
579
- });
580
463
 
581
- const tables = Array.from(map, ([name, columns]) => ({ name, columns }));
582
-
583
- const indexesRows = deps.sqlite.exec(
584
- allIndexes
585
- ? sql`
586
- select name, sql
587
- from sqlite_master
588
- where type = 'index' and name not like 'sqlite_%';
589
- `
590
- : sql`
591
- select name, sql
592
- from sqlite_master
593
- where
594
- type = 'index'
595
- and name not like 'sqlite_%'
596
- and name not like 'evolu_%';
597
- `,
598
- );
464
+ const deps: DbWorkerDeps = {
465
+ ...platformDeps,
466
+ getQueryRowsCache,
467
+ postMessage,
468
+ sqlite,
469
+ sync: sync.value,
470
+ appOwner,
471
+ };
599
472
 
600
- if (!indexesRows.ok) return indexesRows;
473
+ return ok(deps);
474
+ });
601
475
 
602
- const indexes = indexesRows.value.rows.map(
603
- (row): DbIndex => ({
604
- name: row.name as string,
605
- /**
606
- * SQLite returns "CREATE INDEX" for "create index" for some reason.
607
- * Other keywords remain unchanged. We have to normalize the casing for
608
- * {@link indexesAreEqual} manually.
609
- */
610
- sql: (row.sql as string)
611
- .replace("CREATE INDEX", "create index")
612
- .replace("CREATE UNIQUE INDEX", "create unique index"),
613
- }),
614
- );
476
+ if (!deps.ok) {
477
+ postMessage({ type: "onError", error: deps.error });
478
+ return null;
479
+ }
615
480
 
616
- return ok({ tables, indexes });
481
+ return deps.value;
617
482
  };
618
483
 
619
- const indexesAreEqual = (self: DbIndex, that: DbIndex): boolean =>
620
- self.name === that.name && self.sql === that.sql;
621
-
622
- export interface DbSnapshot {
623
- readonly schema: DbSchema;
624
- readonly rows: Array<{
625
- rows: ReadonlyArray<SqliteRow>;
626
- name: string;
627
- }>;
628
- }
629
-
630
- // TODO: Move it to Sqlite.
631
- export const getDbSnapshot = (
632
- deps: SqliteDep,
633
- ): Result<DbSnapshot, SqliteError> => {
634
- const schema = getDbSchema(deps)({ allIndexes: true });
635
- if (!schema.ok) return schema;
636
-
637
- const rows = [];
638
-
639
- for (const table of schema.value.tables) {
640
- const result = deps.sqlite.exec(sql`
641
- select * from ${sql.identifier(table.name)};
642
- `);
643
- if (!result.ok) return result;
644
-
645
- rows.push({
646
- rows: result.value.rows,
647
- name: table.name,
648
- });
649
- }
650
-
651
- return ok({ schema: schema.value, rows });
652
- };
653
-
654
- const ensureDbSchema =
484
+ const initializeDb =
655
485
  (deps: SqliteDep) =>
656
486
  (
657
- newSchema: DbSchema,
658
- currentSchema: DbSchema,
659
- options?: { ignoreIndexes: boolean },
487
+ initialAppOwner: AppOwner,
488
+ initialClock: Timestamp,
660
489
  ): Result<void, SqliteError> => {
661
- const queries: Array<SqliteQuery> = [];
662
-
663
- newSchema.tables.forEach((newTable) => {
664
- const currentTable = currentSchema.tables.find(
665
- (t) => t.name === newTable.name,
666
- );
667
- if (!currentTable) {
668
- queries.push({
669
- sql: createAppTable(newTable.name, newTable.columns),
670
- parameters: [],
671
- });
672
- } else {
673
- newTable.columns
674
- .filter((newColumn) => !currentTable.columns.includes(newColumn))
675
- .forEach((newColumn) => {
676
- queries.push(sql`
677
- alter table ${sql.identifier(newTable.name)}
678
- add column ${sql.identifier(newColumn)} blob;
679
- `);
680
- });
681
- }
682
- });
683
-
684
- if (options?.ignoreIndexes !== true) {
685
- // Remove current indexes that are not in the newSchema.
686
- currentSchema.indexes
687
- .filter(
688
- (currentIndex) =>
689
- !newSchema.indexes.some((newIndex) =>
690
- indexesAreEqual(newIndex, currentIndex),
691
- ),
692
- )
693
- .forEach((index) => {
694
- queries.push(sql`drop index ${sql.identifier(index.name)};`);
695
- });
696
-
697
- // Add new indexes that are not in the currentSchema.
698
- newSchema.indexes
699
- .filter(
700
- (newIndex) =>
701
- !currentSchema.indexes.some((currentIndex) =>
702
- indexesAreEqual(newIndex, currentIndex),
703
- ),
490
+ for (const query of [
491
+ // Never change structure to ensure all versions can read it.
492
+ sql`
493
+ create table evolu_version (
494
+ "protocolVersion" integer not null
704
495
  )
705
- .forEach((newIndex) => {
706
- queries.push({ sql: `${newIndex.sql};` as SafeSql, parameters: [] });
707
- });
708
- }
709
-
710
- for (const query of queries) {
711
- const result = deps.sqlite.exec(query);
712
- if (!result.ok) return result;
713
- }
714
- return ok();
715
- };
716
-
717
- export const createAppTable = (
718
- tableName: string,
719
- columns: ReadonlyArray<string>,
720
- ): SafeSql =>
721
- `
722
- create table ${sql.identifier(tableName).sql} (
723
- "id" text primary key,
724
- ${columns
725
- // Add default columns.
726
- .concat(["createdAt", "updatedAt", "isDeleted"])
727
- .filter((c) => c !== "id")
728
- // "A column with affinity BLOB does not prefer one storage class over another
729
- // and no attempt is made to coerce data from one storage class into another."
730
- // https://www.sqlite.org/datatype3.html
731
- .map((name) => `${sql.identifier(name).sql} blob`)
732
- .join(", ")}
733
- );
734
- ` as SafeSql;
735
-
736
- const selectAppOwner = (
737
- deps: SqliteDep,
738
- ): Result<[AppOwner, OwnerRow], SqliteError> => {
739
- const result = deps.sqlite.exec<OwnerRow>(sql`
740
- select mnemonic, id, createdAt, encryptionKey, writeKey, timestamp
741
- from evolu_owner
742
- order by createdAt asc
743
- limit 1;
744
- `);
745
-
746
- if (!result.ok) return result;
747
-
748
- const {
749
- rows: [ownerRow],
750
- } = result.value;
751
-
752
- assert(ownerRow.writeKey != null, "The writeKey is null");
753
-
754
- const appOwner: AppOwner = {
755
- type: "AppOwner",
756
- mnemonic: ownerRow.mnemonic,
757
- createdAt: ownerRow.createdAt,
758
- id: ownerRow.id,
759
- encryptionKey: ownerRow.encryptionKey,
760
- writeKey: ownerRow.writeKey,
761
- };
496
+ strict;
497
+ `,
762
498
 
763
- return ok([appOwner, ownerRow]);
764
- };
499
+ sql`
500
+ insert into evolu_version ("protocolVersion")
501
+ values (${protocolVersion});
502
+ `,
765
503
 
766
- const initializeDb =
767
- (
768
- deps: SqliteDep &
769
- NanoIdLibDep &
770
- CreateMnemonicDep &
771
- TimeDep &
772
- CreateRandomBytesDep,
773
- ) =>
774
- (mnemonic?: Mnemonic): Result<[AppOwner, OwnerRow], SqliteError> => {
775
- for (const query of [
776
504
  sql`
777
505
  create table evolu_config (
778
- "key" text not null primary key,
779
- "value" any not null
506
+ "clock" text not null,
507
+ "appOwnerId" text not null,
508
+ "appOwnerEncryptionKey" blob not null,
509
+ "appOwnerWriteKey" blob not null,
510
+ "appOwnerMnemonic" text
780
511
  )
781
512
  strict;
782
513
  `,
783
514
 
784
515
  sql`
785
- insert into evolu_config ("key", "value")
786
- values ('protocolVersion', ${protocolVersion});
516
+ insert into evolu_config
517
+ (
518
+ "clock",
519
+ "appOwnerId",
520
+ "appOwnerEncryptionKey",
521
+ "appOwnerWriteKey",
522
+ "appOwnerMnemonic"
523
+ )
524
+ values
525
+ (
526
+ ${timestampToTimestampString(initialClock)},
527
+ ${initialAppOwner.id},
528
+ ${initialAppOwner.encryptionKey},
529
+ ${initialAppOwner.writeKey},
530
+ ${initialAppOwner.mnemonic ?? null}
531
+ );
787
532
  `,
788
533
 
789
534
  /**
790
- * The History table stores all values per timestamp, table, row, and
791
- * column. It's required for merging without conflicts. Evolu uses
792
- * last-write-win CRDT. In case of last-write-win value isn't what we
793
- * want, we can use time travel. The current implementation prefers
794
- * performance over storage size. The History table denormalizes Timestamp
795
- * and DbChange to leverage a covering index. Hence, every value change
796
- * has its timestamp, table, row, and column. In the future, we will
797
- * rethink that and store history more efficiently.
798
- *
799
- * There is no need to use `OwnerId` on the client, because timestamps
800
- * (which include a `NodeId`) are globally unique. The relay needs
801
- * `OwnerId` because it hosts multiple apps/owners, but client storage
802
- * represents one DB, and even when it hosts multiple owners, their
803
- * timestamps remain unique.
535
+ * The History table stores all values per ownerId, timestamp, table, id,
536
+ * and column for conflict-free merging using last-write-win CRDT.
537
+ * Denormalizes Timestamp and DbChange for covering index performance.
538
+ * Time travel is available when last-write-win isn't desired. Future
539
+ * optimization will store history more efficiently.
804
540
  */
805
541
  sql`
806
542
  create table evolu_history (
807
- "timestamp" blob not null,
543
+ "ownerId" blob not null,
808
544
  "table" text not null,
809
- "row" blob not null,
545
+ "id" blob not null,
810
546
  "column" text not null,
547
+ "timestamp" blob not null,
811
548
  "value" any
812
549
  )
813
550
  strict;
814
551
  `,
815
552
 
553
+ // Index for reading database changes by owner and timestamp.
554
+ // Timestamp always corresponds to a DbChange.
816
555
  sql`
817
- create index evolu_history_timestamp on evolu_history ("timestamp");
556
+ create index evolu_history_ownerId_timestamp on evolu_history (
557
+ "ownerId",
558
+ "timestamp"
559
+ );
818
560
  `,
819
561
 
820
562
  sql`
821
- create unique index evolu_history_row_column_table_timestampDesc on evolu_history (
822
- "row",
823
- "column",
563
+ create unique index evolu_history_ownerId_table_id_column_timestampDesc on evolu_history (
564
+ "ownerId",
824
565
  "table",
566
+ "id",
567
+ "column",
825
568
  "timestamp" desc
826
569
  );
827
570
  `,
828
-
829
- sql`
830
- create table evolu_owner (
831
- "mnemonic" text not null primary key,
832
- "id" text not null,
833
- "encryptionKey" blob not null,
834
- "createdAt" text not null,
835
- "writeKey" blob,
836
- "timestamp" text not null
837
- )
838
- strict;
839
- `,
840
571
  ]) {
841
572
  const result = deps.sqlite.exec(query);
842
573
  if (!result.ok) return result;
843
574
  }
844
575
 
845
- const appOwner = createAppOwner(deps)(mnemonic);
846
- const ownerRow = createOwnerRow(deps)(appOwner);
847
-
848
- const result = deps.sqlite.exec(sql`
849
- insert into evolu_owner
850
- (mnemonic, id, encryptionKey, createdAt, writeKey, timestamp)
851
- values
852
- (
853
- ${ownerRow.mnemonic},
854
- ${ownerRow.id},
855
- ${ownerRow.encryptionKey},
856
- ${ownerRow.createdAt},
857
- ${ownerRow.writeKey},
858
- ${ownerRow.timestamp}
859
- );
860
- `);
576
+ return ok();
577
+ };
861
578
 
862
- if (!result.ok) return result;
579
+ const handlers: Omit<MessageHandlers<DbWorkerInput, DbWorkerDeps>, "init"> = {
580
+ getAppOwner: (deps) => () => {
581
+ deps.postMessage({
582
+ type: "onGetAppOwner",
583
+ appOwner: deps.appOwner,
584
+ });
585
+ },
863
586
 
864
- return ok([appOwner, ownerRow]);
865
- };
587
+ mutate: (deps) => (message) => {
588
+ const mutate = deps.sqlite.transaction(() => {
589
+ const syncChanges: Array<MutationChange> = [];
866
590
 
867
- const applyChanges =
868
- (
869
- deps: SqliteDep &
870
- TimeDep &
871
- TimestampConfigDep &
872
- RandomDep &
873
- OwnerRowRefDep &
874
- ClientStorageDep,
875
- ) =>
876
- (
877
- changes: NonEmptyReadonlyArray<DbChange>,
878
- onChange?: () => void,
879
- ): Result<
880
- NonEmptyReadonlyArray<CrdtMessage>,
881
- | TimestampTimeOutOfRangeError
882
- | TimestampDriftError
883
- | TimestampCounterOverflowError
884
- | SqliteError
885
- > => {
886
- let lastTimestamp = timestampStringToTimestamp(
887
- deps.ownerRowRef.get().timestamp,
888
- );
591
+ for (const change of message.changes) {
592
+ const isLocalOnlyChange = change.table.startsWith("_");
593
+ if (isLocalOnlyChange) {
594
+ const result = applyLocalOnlyChange(deps)(change);
595
+ if (!result.ok) return result;
596
+ } else {
597
+ syncChanges.push(change);
598
+ }
599
+ }
600
+
601
+ if (isNonEmptyArray(syncChanges)) {
602
+ const result = deps.sync.applyChanges(syncChanges);
603
+ if (!result.ok) return result;
604
+ }
605
+
606
+ // Read writes before commit to update UI ASAP
607
+ const queryPatches = loadQueries(deps)(
608
+ message.tabId,
609
+ message.subscribedQueries,
610
+ );
611
+ if (!queryPatches.ok) return queryPatches;
612
+
613
+ // Update the tab that performed the mutation.
614
+ deps.postMessage({
615
+ type: "onQueryPatches",
616
+ tabId: message.tabId,
617
+ queryPatches: queryPatches.value,
618
+ onCompleteIds: message.onCompleteIds,
619
+ });
889
620
 
890
- const messages: Array<CrdtMessage> = [];
621
+ // Notify other tabs to refresh their queries.
622
+ deps.postMessage({ type: "refreshQueries", tabId: message.tabId });
623
+
624
+ return ok();
625
+ });
891
626
 
892
- for (const change of changes) {
893
- const nextTimestamp = sendTimestamp(deps)(lastTimestamp);
894
- if (!nextTimestamp.ok) return nextTimestamp;
895
- lastTimestamp = nextTimestamp.value;
896
- messages.push({ timestamp: lastTimestamp, change });
627
+ if (!mutate.ok) {
628
+ deps.postMessage({ type: "onError", error: mutate.error });
629
+ return;
897
630
  }
631
+ },
898
632
 
899
- const apply = applyMessages(deps)(messages, lastTimestamp);
900
- if (!apply.ok) return apply;
633
+ query: (deps) => (message) => {
634
+ const queryPatches = loadQueries(deps)(message.tabId, message.queries);
901
635
 
902
- if (onChange) onChange();
636
+ if (!queryPatches.ok) {
637
+ deps.postMessage({ type: "onError", error: queryPatches.error });
638
+ return;
639
+ }
903
640
 
904
- assertNonEmptyReadonlyArray(messages);
905
- return ok(messages);
906
- };
641
+ deps.postMessage({
642
+ type: "onQueryPatches",
643
+ tabId: message.tabId,
644
+ queryPatches: queryPatches.value,
645
+ onCompleteIds: [],
646
+ });
647
+ },
907
648
 
908
- const applyMessages =
909
- (deps: SqliteDep & RandomDep & OwnerRowRefDep & ClientStorageDep) =>
910
- (
911
- messages: ReadonlyArray<CrdtMessage>,
912
- lastTimestamp: Timestamp,
913
- options: { isMigrationFromVersion0?: boolean } = {},
914
- ): Result<void, SqliteError> => {
915
- for (const message of messages) {
916
- if (!options.isMigrationFromVersion0) {
917
- const apply1 = applyMessageToAppTable(deps)(message);
918
- if (!apply1.ok) return apply1;
649
+ reset: (deps) => (message) => {
650
+ const resetResult = deps.sqlite.transaction(() => {
651
+ const dbSchema = getDbSchema(deps)();
652
+ if (!dbSchema.ok) return dbSchema;
653
+
654
+ for (const table of dbSchema.value.tables) {
655
+ /**
656
+ * The dropped table is completely removed from the database schema and
657
+ * the disk file. The table can not be recovered. All indices and
658
+ * triggers associated with the table are also deleted.
659
+ * https://sqlite.org/lang_droptable.html
660
+ */
661
+ const result = deps.sqlite.exec(sql`
662
+ drop table ${sql.identifier(table.name)};
663
+ `);
664
+ if (!result.ok) return result;
919
665
  }
920
666
 
921
- const apply2 = applyMessageToTimestampAndHistoryTables(deps)(message);
922
- if (!apply2.ok) return apply2;
923
- }
667
+ if (message.restore) {
668
+ const dbSchema = getDbSchema(deps)();
669
+ if (!dbSchema.ok) return dbSchema;
924
670
 
925
- const timestamp = timestampToTimestampString(lastTimestamp);
926
- deps.ownerRowRef.modify((owner) => ({ ...owner, timestamp }));
927
- const saveTimestamp = deps.sqlite.exec(sql.prepared`
928
- update evolu_owner set "timestamp" = ${timestamp};
929
- `);
930
- if (!saveTimestamp.ok) return saveTimestamp;
671
+ const ensureDbSchemaResult = ensureDbSchema(deps)(
672
+ message.restore.dbSchema,
673
+ dbSchema.value,
674
+ );
675
+ if (!ensureDbSchemaResult.ok) return ensureDbSchemaResult;
931
676
 
932
- return ok();
933
- };
677
+ const secret = mnemonicToOwnerSecret(message.restore.mnemonic);
678
+ const appOwner = createAppOwner(secret);
679
+ const clock = createClock(deps)();
934
680
 
935
- const applyMessageToAppTable =
936
- (deps: SqliteDep) =>
937
- (message: CrdtMessage): Result<void, SqliteError> => {
938
- const date = new Date(message.timestamp.millis).toISOString();
939
- const timestamp = timestampToBinaryTimestamp(message.timestamp);
940
-
941
- for (const [column, value] of objectToEntries(message.change.values)) {
942
- const result = deps.sqlite.exec(sql.prepared`
943
- with
944
- lastTimestamp as (
945
- select timestamp
946
- from evolu_history
947
- where
948
- "row" = ${message.change.id}
949
- and "column" = ${column}
950
- and "table" = ${message.change.table}
951
- order by timestamp desc
952
- limit 1
953
- )
954
- insert into ${sql.identifier(message.change.table)}
955
- ("id", ${sql.identifier(column)}, createdAt, updatedAt)
956
- select ${message.change.id}, ${value}, ${date}, ${date}
957
- where
958
- (select timestamp from lastTimestamp) is null
959
- or (select timestamp from lastTimestamp) < ${timestamp}
960
- on conflict ("id") do update
961
- set
962
- ${sql.identifier(column)} = ${value},
963
- updatedAt = ${date}
964
- where
965
- (select timestamp from lastTimestamp) is null
966
- or (select timestamp from lastTimestamp) < ${timestamp};
967
- `);
681
+ const initializeDbResult = initializeDb(deps)(appOwner, clock.get());
682
+ if (!initializeDbResult.ok) return initializeDbResult;
683
+ }
968
684
 
969
- if (!result.ok) return result;
685
+ return ok();
686
+ });
687
+
688
+ if (!resetResult.ok) {
689
+ deps.postMessage({ type: "onError", error: resetResult.error });
690
+ return;
970
691
  }
971
692
 
972
- return ok();
973
- };
693
+ deps.postMessage({
694
+ type: "onReset",
695
+ onCompleteId: message.onCompleteId,
696
+ reload: message.reload,
697
+ });
698
+ },
974
699
 
975
- export const applyMessageToTimestampAndHistoryTables =
976
- (deps: SqliteDep & RandomDep & OwnerRowRefDep & ClientStorageDep) =>
977
- (message: CrdtMessage): Result<void, SqliteError> => {
978
- const timestamp = timestampToBinaryTimestamp(message.timestamp);
979
- const ownerId = ownerIdToBinaryOwnerId(deps.ownerRowRef.get().id);
980
- const id = idToBinaryId(message.change.id);
700
+ ensureDbSchema: (deps) => (message) => {
701
+ const ensureSchema = deps.sqlite.transaction(() => {
702
+ const dbSchema = getDbSchema(deps)();
703
+ if (!dbSchema.ok) return dbSchema;
981
704
 
982
- const result = deps.storage.insertTimestamp(ownerId, timestamp);
983
- if (!result.ok) return result;
705
+ const ensureDbSchemaResult = ensureDbSchema(deps)(
706
+ message.dbSchema,
707
+ dbSchema.value,
708
+ );
709
+ if (!ensureDbSchemaResult.ok) return ensureDbSchemaResult;
984
710
 
985
- for (const [column, value] of Object.entries(message.change.values)) {
986
- const result = deps.sqlite.exec(sql.prepared`
987
- insert into evolu_history
988
- ("timestamp", "table", "row", "column", "value")
989
- values
990
- (${timestamp}, ${message.change.table}, ${id}, ${column}, ${value})
991
- on conflict do nothing;
992
- `);
993
- if (!result.ok) return result;
711
+ return ok();
712
+ });
713
+
714
+ if (!ensureSchema.ok) {
715
+ deps.postMessage({ type: "onError", error: ensureSchema.error });
716
+ return;
994
717
  }
718
+ },
995
719
 
996
- return ok();
997
- };
720
+ export: (deps) => (message) => {
721
+ const file = deps.sqlite.export();
722
+
723
+ if (!file.ok) {
724
+ deps.postMessage({ type: "onError", error: file.error });
725
+ return;
726
+ }
727
+
728
+ deps.postMessage({
729
+ type: "onExport",
730
+ onCompleteId: message.onCompleteId,
731
+ file: file.value,
732
+ });
733
+ },
734
+
735
+ useOwner: (deps) => (message) => {
736
+ deps.sync.useOwner(message.use, message.owner);
737
+ },
738
+ };
998
739
 
999
740
  const loadQueries =
1000
741
  (deps: GetQueryRowsCacheDep & SqliteDep) =>
@@ -1033,243 +774,3 @@ const loadQueries =
1033
774
  );
1034
775
  return ok(queryPatchesArray);
1035
776
  };
1036
-
1037
- export const maybeMigrateToVersion0 =
1038
- (deps: SqliteDep) =>
1039
- (
1040
- schema: DbSchema,
1041
- ): Result<
1042
- {
1043
- readonly messages: ReadonlyArray<CrdtMessage>;
1044
- readonly mnemonic: Mnemonic;
1045
- readonly lastTimestamp: Timestamp;
1046
- } | null,
1047
- SqliteError
1048
- > => {
1049
- // evolu_history is a new table
1050
- const hasOwnerButNoHistory =
1051
- schema.tables.some((t) => t.name === "evolu_owner") &&
1052
- !schema.tables.some((t) => t.name === "evolu_history");
1053
- if (!hasOwnerButNoHistory) {
1054
- return ok(null);
1055
- }
1056
-
1057
- const mnemonicAndLastTimestamp = deps.sqlite.exec<{
1058
- mnemonic: Mnemonic;
1059
- timestamp: TimestampString;
1060
- }>(sql` select mnemonic, timestamp from evolu_owner limit 1; `);
1061
- if (!mnemonicAndLastTimestamp.ok) return mnemonicAndLastTimestamp;
1062
-
1063
- const messagesRows = deps.sqlite.exec<{
1064
- timestamp: TimestampString;
1065
- table: Base64Url256;
1066
- row: Id;
1067
- column: Base64Url256;
1068
- value: SqliteValue;
1069
- }>(sql`
1070
- select "timestamp", "table", "row", "column", "value" from evolu_message;
1071
- `);
1072
-
1073
- if (!messagesRows.ok) return messagesRows;
1074
-
1075
- for (const query of [
1076
- sql`drop table evolu_owner;`,
1077
- sql`drop table evolu_message;`,
1078
- ]) {
1079
- const result = deps.sqlite.exec(query);
1080
- if (!result.ok) return result;
1081
- }
1082
-
1083
- const messages = messagesRows.value.rows.map((message) => ({
1084
- timestamp: timestampStringToTimestamp(message.timestamp),
1085
- change: {
1086
- id: message.row,
1087
- table: message.table,
1088
- values: { [message.column]: message.value },
1089
- },
1090
- }));
1091
-
1092
- const {
1093
- rows: [{ mnemonic, timestamp }],
1094
- } = mnemonicAndLastTimestamp.value;
1095
- const lastTimestamp = timestampStringToTimestamp(timestamp);
1096
-
1097
- return ok({ messages, mnemonic, lastTimestamp });
1098
- };
1099
-
1100
- const dropAllTables = (deps: SqliteDep): Result<void, SqliteError> => {
1101
- const schema = getDbSchema(deps)();
1102
- if (!schema.ok) return schema;
1103
- for (const table of schema.value.tables) {
1104
- /**
1105
- * The dropped table is completely removed from the database schema and the
1106
- * disk file. The table can not be recovered. All indices and triggers
1107
- * associated with the table are also deleted.
1108
- * https://sqlite.org/lang_droptable.html
1109
- */
1110
- const result = deps.sqlite.exec(sql`
1111
- drop table ${sql.identifier(table.name)};
1112
- `);
1113
- if (!result.ok) return result;
1114
- }
1115
- return ok();
1116
- };
1117
-
1118
- const handleSyncOpen =
1119
- (deps: OwnerRowRefDep & StorageDep & ConsoleDep): SyncConfig["onOpen"] =>
1120
- (send) => {
1121
- const ownerId = deps.ownerRowRef.get().id;
1122
- const message = createProtocolMessageForSync(deps)(ownerId);
1123
- if (message) {
1124
- deps.console.log("[db]", "send initial sync message", message);
1125
- send(message);
1126
- }
1127
- };
1128
-
1129
- const createHandleSyncMessage =
1130
- (
1131
- deps: PostMessageDep & StorageDep & SqliteDep & ConsoleDep & OwnerRowRefDep,
1132
- ): SyncConfig["onMessage"] =>
1133
- (input, send) => {
1134
- deps.console.log("[db]", "receive sync message", input);
1135
- const { writeKey } = deps.ownerRowRef.get();
1136
-
1137
- const output = deps.sqlite.transaction(() =>
1138
- applyProtocolMessageAsClient(deps)(input, {
1139
- getWriteKey: (_ownerId) => writeKey,
1140
- }),
1141
- );
1142
- if (!output.ok) {
1143
- deps.postMessage({ type: "onError", error: output.error });
1144
- return;
1145
- }
1146
-
1147
- if (output.value) {
1148
- deps.console.log("[db]", "respond sync message", output.value);
1149
- send(output.value);
1150
- }
1151
- };
1152
-
1153
- export interface ClientStorage extends SqliteStorageBase, Storage {}
1154
-
1155
- export interface ClientStorageDep {
1156
- readonly storage: ClientStorage;
1157
- }
1158
-
1159
- const createClientStorage =
1160
- (
1161
- deps: SqliteDep &
1162
- PostMessageDep &
1163
- SymmetricCryptoDep &
1164
- OwnerRowRefDep &
1165
- RandomDep &
1166
- TimeDep &
1167
- TimestampConfigDep,
1168
- ) =>
1169
- (
1170
- options: CreateSqliteStorageBaseOptions,
1171
- ): Result<ClientStorage, SqliteError> => {
1172
- const sqliteStorageBase = createSqliteStorageBase(deps)(options);
1173
- if (!sqliteStorageBase.ok) return sqliteStorageBase;
1174
-
1175
- const storage: ClientStorage = {
1176
- ...sqliteStorageBase.value,
1177
-
1178
- validateWriteKey: constFalse,
1179
-
1180
- writeMessages: (_ownerId, messages) => {
1181
- // TODO: Get owner by _ownerId when we support more.
1182
- const owner = deps.ownerRowRef.get();
1183
- const decodedAndDecryptedMessages: Array<CrdtMessage> = [];
1184
-
1185
- for (const message of messages) {
1186
- const dbChange = decryptAndDecodeDbChange(deps)(
1187
- message.change,
1188
- owner.encryptionKey,
1189
- );
1190
-
1191
- if (!dbChange.ok) {
1192
- deps.postMessage({
1193
- type: "onError",
1194
- error: dbChange.error,
1195
- });
1196
- return false;
1197
- }
1198
-
1199
- decodedAndDecryptedMessages.push({
1200
- timestamp: message.timestamp,
1201
- change: dbChange.value,
1202
- });
1203
- }
1204
-
1205
- let timestamp = timestampStringToTimestamp(owner.timestamp);
1206
-
1207
- for (const message of messages) {
1208
- const receive = receiveTimestamp(deps)(timestamp, message.timestamp);
1209
- if (!receive.ok) {
1210
- deps.postMessage({ type: "onError", error: receive.error });
1211
- return false;
1212
- }
1213
- timestamp = receive.value;
1214
- }
1215
-
1216
- const applyMessagesResult = applyMessages({ ...deps, storage })(
1217
- decodedAndDecryptedMessages,
1218
- timestamp,
1219
- );
1220
-
1221
- if (!applyMessagesResult.ok) {
1222
- deps.postMessage({
1223
- type: "onError",
1224
- error: applyMessagesResult.error,
1225
- });
1226
- return false;
1227
- }
1228
-
1229
- deps.postMessage({ type: "onReceive" });
1230
-
1231
- return true;
1232
- },
1233
-
1234
- readDbChange: (_ownerId, timestamp) => {
1235
- const result = deps.sqlite.exec<{
1236
- table: Base64Url256;
1237
- row: BinaryId;
1238
- column: Base64Url256;
1239
- value: SqliteValue;
1240
- }>(sql`
1241
- select "table", "row", "column", "value"
1242
- from evolu_history
1243
- where "timestamp" = ${timestamp};
1244
- `);
1245
- if (!result.ok) {
1246
- deps.postMessage({ type: "onError", error: result.error });
1247
- return null;
1248
- }
1249
-
1250
- const { rows } = result.value;
1251
- assert(rows.length > 0, "Rows must not be empty");
1252
-
1253
- const { table, row } = rows[0];
1254
- const values: Record<string, SqliteValue> = {};
1255
-
1256
- for (const r of rows) {
1257
- assert(r.table === table, "All rows must have the same table");
1258
- assert(eqArrayNumber(r.row, row), "All rows must have the same Id");
1259
- values[r.column] = r.value;
1260
- }
1261
-
1262
- const change: DbChange = {
1263
- table: rows[0].table,
1264
- id: binaryIdToId(rows[0].row),
1265
- values,
1266
- };
1267
-
1268
- const { encryptionKey } = deps.ownerRowRef.get();
1269
-
1270
- return encodeAndEncryptDbChange(deps)(change, encryptionKey);
1271
- },
1272
- };
1273
-
1274
- return ok(storage);
1275
- };