@evolu/common 8.10.0 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/src/Bytes.d.ts +39 -2
  2. package/dist/src/Bytes.d.ts.map +1 -1
  3. package/dist/src/Bytes.js +50 -2
  4. package/dist/src/Config.d.ts +22 -22
  5. package/dist/src/Config.d.ts.map +1 -1
  6. package/dist/src/Console.d.ts +62 -7
  7. package/dist/src/Console.d.ts.map +1 -1
  8. package/dist/src/Console.js +20 -4
  9. package/dist/src/Crypto.d.ts +76 -4
  10. package/dist/src/Crypto.d.ts.map +1 -1
  11. package/dist/src/Crypto.js +55 -4
  12. package/dist/src/Error.d.ts +45 -0
  13. package/dist/src/Error.d.ts.map +1 -1
  14. package/dist/src/Error.js +69 -0
  15. package/dist/src/Fs.d.ts +92 -18
  16. package/dist/src/Fs.d.ts.map +1 -1
  17. package/dist/src/Fs.js +2 -0
  18. package/dist/src/Identicon.d.ts +2 -2
  19. package/dist/src/Identicon.js +2 -2
  20. package/dist/src/LeakDetector.d.ts +22 -3
  21. package/dist/src/LeakDetector.d.ts.map +1 -1
  22. package/dist/src/LeakDetector.js +12 -2
  23. package/dist/src/LockManager.d.ts +8 -0
  24. package/dist/src/LockManager.d.ts.map +1 -1
  25. package/dist/src/LockManager.js +6 -0
  26. package/dist/src/Object.d.ts.map +1 -1
  27. package/dist/src/Object.js +5 -0
  28. package/dist/src/Platform.d.ts +47 -7
  29. package/dist/src/Platform.d.ts.map +1 -1
  30. package/dist/src/Platform.js +24 -5
  31. package/dist/src/Random.d.ts +25 -2
  32. package/dist/src/Random.d.ts.map +1 -1
  33. package/dist/src/Random.js +14 -2
  34. package/dist/src/Resource.d.ts +156 -1
  35. package/dist/src/Resource.d.ts.map +1 -1
  36. package/dist/src/Resource.js +201 -72
  37. package/dist/src/Schedule.d.ts +11 -10
  38. package/dist/src/Schedule.d.ts.map +1 -1
  39. package/dist/src/Schedule.js +1 -1
  40. package/dist/src/Sqlite.d.ts +132 -16
  41. package/dist/src/Sqlite.d.ts.map +1 -1
  42. package/dist/src/Sqlite.js +63 -9
  43. package/dist/src/Task.d.ts +15 -4
  44. package/dist/src/Task.d.ts.map +1 -1
  45. package/dist/src/Task.js +41 -15
  46. package/dist/src/Test.d.ts +9 -0
  47. package/dist/src/Test.d.ts.map +1 -1
  48. package/dist/src/Test.js +4 -0
  49. package/dist/src/Time.d.ts +106 -9
  50. package/dist/src/Time.d.ts.map +1 -1
  51. package/dist/src/Time.js +55 -4
  52. package/dist/src/Type.d.ts +1455 -1310
  53. package/dist/src/Type.d.ts.map +1 -1
  54. package/dist/src/Type.js +1274 -517
  55. package/dist/src/WebSocket.d.ts +164 -13
  56. package/dist/src/WebSocket.d.ts.map +1 -1
  57. package/dist/src/WebSocket.js +133 -24
  58. package/dist/src/Worker.d.ts +90 -8
  59. package/dist/src/Worker.d.ts.map +1 -1
  60. package/dist/src/Worker.js +28 -2
  61. package/dist/src/index.d.ts +6 -7
  62. package/dist/src/index.d.ts.map +1 -1
  63. package/dist/src/index.js +2 -3
  64. package/dist/src/local-first/Db.d.ts +52 -3
  65. package/dist/src/local-first/Db.d.ts.map +1 -1
  66. package/dist/src/local-first/Db.js +412 -137
  67. package/dist/src/local-first/Evolu.d.ts +412 -213
  68. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  69. package/dist/src/local-first/Evolu.js +181 -18
  70. package/dist/src/local-first/Owner.d.ts +13 -30
  71. package/dist/src/local-first/Owner.d.ts.map +1 -1
  72. package/dist/src/local-first/Owner.js +13 -30
  73. package/dist/src/local-first/Protocol.d.ts +106 -19
  74. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  75. package/dist/src/local-first/Protocol.js +162 -60
  76. package/dist/src/local-first/Query.d.ts +8 -15
  77. package/dist/src/local-first/Query.d.ts.map +1 -1
  78. package/dist/src/local-first/Relay.d.ts.map +1 -1
  79. package/dist/src/local-first/Relay.js +4 -2
  80. package/dist/src/local-first/Schema.d.ts +346 -23
  81. package/dist/src/local-first/Schema.d.ts.map +1 -1
  82. package/dist/src/local-first/Schema.js +214 -17
  83. package/dist/src/local-first/Shared.d.ts +537 -22
  84. package/dist/src/local-first/Shared.d.ts.map +1 -1
  85. package/dist/src/local-first/Shared.js +1437 -234
  86. package/dist/src/local-first/Storage.d.ts +195 -17
  87. package/dist/src/local-first/Storage.d.ts.map +1 -1
  88. package/dist/src/local-first/Storage.js +85 -22
  89. package/dist/src/local-first/Timestamp.d.ts +392 -41
  90. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  91. package/dist/src/local-first/Timestamp.js +403 -81
  92. package/dist/src/local-first/index.d.ts +0 -1
  93. package/dist/src/local-first/index.d.ts.map +1 -1
  94. package/dist/src/local-first/index.js +0 -1
  95. package/package.json +1 -1
  96. package/src/Assert.test.ts +2 -5
  97. package/src/Bytes.test.ts +27 -0
  98. package/src/Bytes.ts +58 -2
  99. package/src/Config.test.ts +2 -6
  100. package/src/Config.ts +133 -133
  101. package/src/Console.ts +62 -7
  102. package/src/Crypto.ts +76 -4
  103. package/src/Eq.test.ts +2 -3
  104. package/src/Error.test.ts +76 -3
  105. package/src/Error.ts +71 -0
  106. package/src/Fs.ts +92 -18
  107. package/src/Identicon.ts +2 -2
  108. package/src/LeakDetector.ts +22 -3
  109. package/src/LockManager.ts +8 -0
  110. package/src/Object.test.ts +27 -12
  111. package/src/Object.ts +5 -0
  112. package/src/Platform.ts +50 -8
  113. package/src/Random.ts +25 -2
  114. package/src/Resource.test.ts +837 -0
  115. package/src/Resource.ts +235 -15
  116. package/src/Schedule.test.ts +50 -12
  117. package/src/Schedule.ts +24 -14
  118. package/src/Sqlite.ts +137 -17
  119. package/src/Task.test.ts +189 -8
  120. package/src/Task.ts +56 -17
  121. package/src/Test.ts +9 -0
  122. package/src/Time.ts +106 -9
  123. package/src/Type.test.ts +946 -1028
  124. package/src/Type.ts +4195 -3136
  125. package/src/Types.test.ts +4 -14
  126. package/src/WebSocket.ts +313 -40
  127. package/src/Worker.ts +90 -8
  128. package/src/index.ts +20 -6
  129. package/src/local-first/Db.ts +644 -339
  130. package/src/local-first/Evolu.test.ts +994 -22
  131. package/src/local-first/Evolu.ts +625 -232
  132. package/src/local-first/Owner.ts +13 -30
  133. package/src/local-first/Protocol.test.ts +634 -10
  134. package/src/local-first/Protocol.ts +255 -109
  135. package/src/local-first/Query.ts +8 -15
  136. package/src/local-first/Relay.ts +4 -2
  137. package/src/local-first/Schema.test.ts +143 -0
  138. package/src/local-first/Schema.ts +376 -26
  139. package/src/local-first/Shared.test.ts +7731 -559
  140. package/src/local-first/Shared.ts +2036 -267
  141. package/src/local-first/Storage.ts +224 -36
  142. package/src/local-first/Timestamp.test.ts +344 -70
  143. package/src/local-first/Timestamp.ts +434 -118
  144. package/src/local-first/index.ts +0 -1
  145. package/dist/src/local-first/Error.d.ts +0 -12
  146. package/dist/src/local-first/Error.d.ts.map +0 -1
  147. package/dist/src/local-first/Error.js +0 -6
  148. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  149. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  150. package/dist/src/local-first/LocalAuth.js +0 -179
  151. package/src/local-first/Error.ts +0 -17
  152. package/src/local-first/LocalAuth.ts +0 -457
@@ -15,9 +15,12 @@ import {
15
15
  assertNonEmptyReadonlyArray,
16
16
  assertNotUndefined,
17
17
  } from "../Assert.ts";
18
+ import { createBuffer } from "../Bytes.ts";
18
19
  import { createCallbacks } from "../Callbacks.ts";
19
20
  import type { ConsoleDep } from "../Console.ts";
20
21
  import { createConsole } from "../Console.ts";
22
+ import type { DecryptWithXChaCha20Poly1305Error } from "../Crypto.ts";
23
+ import type { UnknownError } from "../Error.ts";
21
24
  import { createUnknownError } from "../Error.ts";
22
25
  import { disposable, exhaustiveCheck, todo } from "../Function.ts";
23
26
  import {
@@ -26,11 +29,20 @@ import {
26
29
  type LockManagerDep,
27
30
  } from "../LockManager.ts";
28
31
  import { createMicrotaskBatch } from "../Microtask.ts";
32
+ import {
33
+ createMutableRecord,
34
+ objectToEntries,
35
+ type ReadonlyRecord,
36
+ } from "../Object.ts";
29
37
  import type { FlushSyncDep, ReloadAppDep } from "../Platform.ts";
30
38
  import { createRefCountByKey } from "../RefCount.ts";
31
39
  import { err, ok } from "../Result.ts";
32
40
  import { isNonEmptySet } from "../Set.ts";
33
- import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.ts";
41
+ import {
42
+ SqliteBoolean,
43
+ sqliteBooleanToBoolean,
44
+ type SqliteValue,
45
+ } from "../Sqlite.ts";
34
46
  import type { Listener, ReadonlyStore, Unsubscribe } from "../Store.ts";
35
47
  import { createStore } from "../Store.ts";
36
48
  import type { Task } from "../Task.ts";
@@ -39,17 +51,18 @@ import {
39
51
  createId,
40
52
  createIdFromString,
41
53
  type ExtractTyped,
42
- type Id,
54
+ Id,
43
55
  Name,
56
+ PositiveInt,
44
57
  type TypeError,
45
58
  UrlSafeString,
46
59
  } from "../Type.ts";
47
60
  import type {
61
+ BroadcastChannel,
48
62
  CreateBroadcastChannelDep,
49
63
  CreateMessageChannelDep,
50
64
  } from "../Worker.ts";
51
- import type { CreateDbWorkerDep } from "./Db.ts";
52
- import type { EvoluError } from "./Error.ts";
65
+ import type { CreateDbWorkerDep, UnsupportedDbVersionError } from "./Db.ts";
53
66
  import type {
54
67
  AppOwner,
55
68
  Owner,
@@ -59,6 +72,12 @@ import type {
59
72
  SyncOwner,
60
73
  } from "./Owner.ts";
61
74
  import { createOwnerWebSocketTransport } from "./Owner.ts";
75
+ import {
76
+ encodeDbChange,
77
+ type ProtocolError,
78
+ type ProtocolQuotaError,
79
+ type ProtocolVersionError,
80
+ } from "./Protocol.ts";
62
81
  import type {
63
82
  Queries,
64
83
  QueriesToQueryRowsPromises,
@@ -73,30 +92,50 @@ import type {
73
92
  IndexesConfig,
74
93
  Mutation,
75
94
  MutationChange,
95
+ MutationOptions,
96
+ MutationValues,
76
97
  ValidateSchema,
77
98
  } from "./Schema.ts";
78
- import { evoluSchemaToSqliteSchema } from "./Schema.ts";
99
+ import { evoluSchemaToSqliteSchema, isLocalOnlyTable } from "./Schema.ts";
79
100
  import type {
80
101
  ConsoleEntryOrError,
81
102
  EvoluInput,
82
103
  EvoluOutput,
104
+ OtherBuildRunningError,
83
105
  SharedWorkerDep,
106
+ SyncState,
107
+ syncStateToOwnerSyncStates,
84
108
  } from "./Shared.ts";
85
109
  import { consoleEntryOrErrorBroadcastChannelName } from "./Shared.ts";
86
- import { DbChange } from "./Storage.ts";
87
- import type { Timestamp } from "./Timestamp.ts";
110
+ import { DbChange, type StorageQuotaError } from "./Storage.ts";
111
+ import { createTimestamp, type Timestamp } from "./Timestamp.ts";
88
112
 
113
+ /**
114
+ * Configuration for {@link createEvolu}.
115
+ *
116
+ * @group Configuration
117
+ */
89
118
  export interface EvoluConfig {
90
119
  /**
91
- * The app name. Evolu is multitenant - it can run multiple instances
92
- * concurrently. The same app can have multiple instances for different
93
- * accounts.
120
+ * An application name used in logs and local database names.
121
+ *
122
+ * Evolu combines `appName` with the {@link AppOwner} identity to identify the
123
+ * local database. Changing either opens a different database. Keep `appName`
124
+ * stable across ordinary application updates.
125
+ *
126
+ * Instances that share an `appName` and AppOwner open the same database and
127
+ * must use the same schema. Evolu applies the schema of the first instance
128
+ * and later instances join it.
94
129
  *
95
- * Evolu derives the final instance name from `appName` and `appOwner` in
96
- * {@link EvoluConfig}. The derived instance name is used as the SQLite
97
- * database filename and as the log prefix. This ensures that each
98
- * {@link Owner} gets a separate local database while preserving a readable app
99
- * prefix.
130
+ * A different app name lets you create a separate local replica for the same
131
+ * AppOwner—for example, to test a different SQLite implementation or index
132
+ * configuration, or rebuild a replica for debugging while preserving the
133
+ * existing database.
134
+ *
135
+ * Evolu supports running these databases concurrently. Their local separation
136
+ * does not isolate synchronization: replicas synchronizing the same owners
137
+ * through the same relay can still exchange changes. Changing `appName`
138
+ * neither migrates existing local data nor isolates incompatible schemas.
100
139
  *
101
140
  * ### Example
102
141
  *
@@ -132,6 +171,73 @@ export interface EvoluConfig {
132
171
  */
133
172
  readonly appOwner: AppOwner;
134
173
 
174
+ /**
175
+ * Keep the database in memory instead of persisting it on this device.
176
+ *
177
+ * This option controls device persistence independently of synchronization.
178
+ * When synchronization is enabled, data can still be synchronized and
179
+ * persisted remotely.
180
+ *
181
+ * Useful for testing or temporary sessions. Data that exists only in this
182
+ * database is lost when the database closes.
183
+ *
184
+ * The default value is: `false`.
185
+ */
186
+ readonly memoryOnly?: boolean;
187
+
188
+ /**
189
+ * Use the `indexes` option to define SQLite indexes.
190
+ *
191
+ * Table and column names are not typed because Kysely doesn't support it.
192
+ *
193
+ * https://medium.com/@JasonWyatt/squeezing-performance-from-sqlite-indexes-indexes-c4e175f3c346
194
+ *
195
+ * ### Example
196
+ *
197
+ * ```ts
198
+ * import {
199
+ * createEvolu,
200
+ * id,
201
+ * testAppName,
202
+ * testAppOwner,
203
+ * } from "@evolu/common";
204
+ *
205
+ * const Schema = {
206
+ * todo: { id: id("Todo") },
207
+ * todoCategory: { id: id("TodoCategory") },
208
+ * };
209
+ *
210
+ * const _createTodoEvolu = createEvolu(Schema, {
211
+ * appName: testAppName,
212
+ * appOwner: testAppOwner,
213
+ * transports: [],
214
+ * indexes: (create) => [
215
+ * create("todoCreatedAt").on("todo").column("createdAt"),
216
+ * create("todoCategoryCreatedAt")
217
+ * .on("todoCategory")
218
+ * .column("createdAt"),
219
+ * ],
220
+ * });
221
+ * ```
222
+ */
223
+ readonly indexes?: IndexesConfig;
224
+
225
+ /**
226
+ * Called when this instance's local database is deleted.
227
+ *
228
+ * Apps can use this to update UI immediately because the corresponding
229
+ * {@link Evolu} instance becomes unusable after local database deletion.
230
+ */
231
+ readonly onDatabaseDeleted?: () => void;
232
+
233
+ /**
234
+ * Called when local data for an {@link Owner} is deleted.
235
+ *
236
+ * Apps can use this to update UI immediately because that owner stops being
237
+ * used across tabs and instances.
238
+ */
239
+ readonly onOwnerDeleted?: (owner: Owner) => void;
240
+
135
241
  /**
136
242
  * Transport configuration for sync and backup.
137
243
  *
@@ -169,18 +275,11 @@ export interface EvoluConfig {
169
275
  * ```ts
170
276
  * import {
171
277
  * assertEqual,
172
- * createAppOwner,
173
278
  * createOwnerWebSocketTransport,
174
- * createOwnerSecret,
175
- * createRandomBytes,
279
+ * testAppOwner,
176
280
  * type OwnerTransport,
177
281
  * } from "@evolu/common";
178
282
  *
179
- * // Create once, persist the mnemonic securely, and restore it on later runs.
180
- * const appOwner = createAppOwner(
181
- * createOwnerSecret({ randomBytes: createRandomBytes() }),
182
- * );
183
- *
184
283
  * // Use one relay.
185
284
  * const _singleRelay = [
186
285
  * { type: "WebSocket", url: "wss://relay1.example.com" },
@@ -201,91 +300,17 @@ export interface EvoluConfig {
201
300
  * const authenticatedRelay = [
202
301
  * createOwnerWebSocketTransport({
203
302
  * url: "wss://relay.example.com",
204
- * ownerId: appOwner.id,
303
+ * ownerId: testAppOwner.id,
205
304
  * }),
206
305
  * ];
207
306
  *
208
307
  * assertEqual(
209
308
  * authenticatedRelay[0]?.url,
210
- * `wss://relay.example.com?ownerId=${appOwner.id}`,
309
+ * `wss://relay.example.com?ownerId=${testAppOwner.id}`,
211
310
  * );
212
311
  * ```
213
312
  */
214
313
  readonly transports?: ReadonlyArray<OwnerTransport>;
215
-
216
- /**
217
- * Keep local data only in memory instead of persisting it on this device.
218
- * Useful for testing, temporary data, or sensitive data that should not be
219
- * recoverable from local storage after the process ends.
220
- *
221
- * Local data stored in memory is completely destroyed when the process ends.
222
- * Sync can still persist data remotely when transports are enabled.
223
- *
224
- * The default value is: `false`.
225
- */
226
- readonly memoryOnly?: boolean;
227
-
228
- /**
229
- * Use the `indexes` option to define SQLite indexes.
230
- *
231
- * Table and column names are not typed because Kysely doesn't support it.
232
- *
233
- * https://medium.com/@JasonWyatt/squeezing-performance-from-sqlite-indexes-indexes-c4e175f3c346
234
- *
235
- * ### Example
236
- *
237
- * ```ts
238
- * import {
239
- * AppName,
240
- * assertTrue,
241
- * createAppOwner,
242
- * createEvolu,
243
- * createOwnerSecret,
244
- * createRandomBytes,
245
- * id,
246
- * } from "@evolu/common";
247
- *
248
- * const Schema = {
249
- * todo: { id: id("Todo") },
250
- * todoCategory: { id: id("TodoCategory") },
251
- * };
252
- * // Create once, persist the mnemonic securely, and restore it on later runs.
253
- * const appOwner = createAppOwner(
254
- * createOwnerSecret({ randomBytes: createRandomBytes() }),
255
- * );
256
- *
257
- * const createTodoEvolu = createEvolu(Schema, {
258
- * appName: AppName.orThrow("IndexedTodos"),
259
- * appOwner,
260
- * transports: [],
261
- * indexes: (create) => [
262
- * create("todoCreatedAt").on("todo").column("createdAt"),
263
- * create("todoCategoryCreatedAt")
264
- * .on("todoCategory")
265
- * .column("createdAt"),
266
- * ],
267
- * });
268
- *
269
- * assertTrue(typeof createTodoEvolu === "function");
270
- * ```
271
- */
272
- readonly indexes?: IndexesConfig;
273
-
274
- /**
275
- * Called when this instance's local database is deleted.
276
- *
277
- * Apps can use this to update UI immediately because the corresponding
278
- * {@link Evolu} instance becomes unusable after local database deletion.
279
- */
280
- readonly onDatabaseDeleted?: () => void;
281
-
282
- /**
283
- * Called when local data for an {@link Owner} is deleted.
284
- *
285
- * Apps can use this to update UI immediately because that owner stops being
286
- * used across tabs and instances.
287
- */
288
- readonly onOwnerDeleted?: (owner: Owner) => void;
289
314
  }
290
315
 
291
316
  /**
@@ -297,6 +322,8 @@ export interface EvoluConfig {
297
322
  *
298
323
  * Uses the same safe alphabet as {@link UrlSafeString} (letters, digits, `-`,
299
324
  * `_`) and must be between 1 and 41 characters.
325
+ *
326
+ * @group Configuration
300
327
  */
301
328
  export const AppName = /*#__PURE__*/ brand(
302
329
  "AppName",
@@ -309,16 +336,35 @@ export const AppName = /*#__PURE__*/ brand(
309
336
  `The value ${JSON.stringify(error.value)} is not between 1 and 41 characters.`,
310
337
  );
311
338
  export type AppName = typeof AppName.Output;
339
+ /**
340
+ * Error produced when a value is not a valid {@link AppName}.
341
+ *
342
+ * @group Configuration
343
+ */
312
344
  export interface AppNameError extends TypeError<"AppName"> {
313
345
  readonly value: UrlSafeString;
314
346
  }
315
347
 
348
+ /**
349
+ * Stable valid {@link AppName} for tests and examples.
350
+ *
351
+ * @group Testing
352
+ */
316
353
  export const testAppName = /*#__PURE__*/ AppName.orThrow("AppName");
317
354
 
318
355
  /**
319
- * Local-first SQL database with typed queries, mutations, and sync.
356
+ * A local-first SQL database.
357
+ *
358
+ * Stores application data in SQLite on the device, so reads and writes work
359
+ * offline. Provides typed queries, mutations, and reactive subscriptions, with
360
+ * synchronization between devices. Persistent SQLite is encrypted with the
361
+ * {@link AppOwner} key, and synchronized data is end-to-end encrypted.
362
+ *
363
+ * Tables whose names start with `_` stay local, even when the instance
364
+ * synchronizes other data. Use them for device-local data such as application
365
+ * settings or an app-owner registry.
320
366
  *
321
- * TODO: Better docs.
367
+ * @group Core
322
368
  */
323
369
  export interface Evolu<
324
370
  S extends EvoluSchema = EvoluSchema,
@@ -340,32 +386,29 @@ export interface Evolu<
340
386
  * every row has a globally unique, conflict-free identifier without
341
387
  * coordination.
342
388
  *
343
- * Pass `onComplete` when follow-up work must wait until the mutation and its
344
- * query patches have been applied.
389
+ * Pass `onComplete` when follow-up work must wait until the mutation is
390
+ * stored and subscribed queries reflect it. It never runs when the database
391
+ * is unavailable; see {@link Evolu.loadQuery}. A stored change is not always
392
+ * visible; see {@link MutationOptions.onComplete}.
345
393
  *
346
394
  * ### Example
347
395
  *
348
396
  * ```ts
349
397
  * import {
350
398
  * assertType,
351
- * id,
399
+ * type TestEvoluSchema,
352
400
  * NonEmptyTrimmedString100,
353
401
  * type Evolu,
402
+ * type TestTodoId,
354
403
  * } from "@evolu/common";
355
404
  *
356
- * const TodoId = id("Todo");
357
- * type TodoId = typeof TodoId.Output;
358
- * const Schema = {
359
- * todo: { id: TodoId, title: NonEmptyTrimmedString100 },
360
- * };
361
- *
362
- * const insertTodo = (evolu: Evolu<typeof Schema>) =>
405
+ * const insertTodo = (evolu: Evolu<TestEvoluSchema>) =>
363
406
  * evolu.insert("todo", {
364
407
  * title: NonEmptyTrimmedString100.orThrow("Learn Evolu"),
365
408
  * }).id;
366
409
  *
367
410
  * const insertTodoAndNotify = (
368
- * evolu: Evolu<typeof Schema>,
411
+ * evolu: Evolu<TestEvoluSchema>,
369
412
  * onComplete: () => void,
370
413
  * ) =>
371
414
  * evolu.insert(
@@ -379,7 +422,7 @@ export interface Evolu<
379
422
  * ReturnType<typeof insertTodo>,
380
423
  * ReturnType<typeof insertTodoAndNotify>,
381
424
  * ],
382
- * [TodoId, TodoId]
425
+ * [TestTodoId, TestTodoId]
383
426
  * >();
384
427
  * ```
385
428
  *
@@ -397,30 +440,30 @@ export interface Evolu<
397
440
  * ```ts
398
441
  * import {
399
442
  * assertType,
400
- * id,
443
+ * type TestEvoluSchema,
401
444
  * NonEmptyTrimmedString100,
402
445
  * sqliteTrue,
403
446
  * type Evolu,
447
+ * type TestTodoId,
404
448
  * } from "@evolu/common";
405
449
  *
406
- * const TodoId = id("Todo");
407
- * type TodoId = typeof TodoId.Output;
408
- * const Schema = {
409
- * todo: { id: TodoId, title: NonEmptyTrimmedString100 },
410
- * };
411
- *
412
- * const renameTodo = (evolu: Evolu<typeof Schema>, todoId: TodoId) =>
450
+ * const renameTodo = (
451
+ * evolu: Evolu<TestEvoluSchema>,
452
+ * todoId: TestTodoId,
453
+ * ) =>
413
454
  * evolu.update("todo", {
414
455
  * id: todoId,
415
456
  * title: NonEmptyTrimmedString100.orThrow("Updated title"),
416
457
  * }).id;
417
458
  *
418
- * const softDeleteTodo = (evolu: Evolu<typeof Schema>, todoId: TodoId) =>
419
- * evolu.update("todo", { id: todoId, isDeleted: sqliteTrue }).id;
459
+ * const softDeleteTodo = (
460
+ * evolu: Evolu<TestEvoluSchema>,
461
+ * todoId: TestTodoId,
462
+ * ) => evolu.update("todo", { id: todoId, isDeleted: sqliteTrue }).id;
420
463
  *
421
464
  * assertType<
422
465
  * [ReturnType<typeof renameTodo>, ReturnType<typeof softDeleteTodo>],
423
- * [TodoId, TodoId]
466
+ * [TestTodoId, TestTodoId]
424
467
  * >();
425
468
  * ```
426
469
  *
@@ -446,41 +489,77 @@ export interface Evolu<
446
489
  * import {
447
490
  * assertType,
448
491
  * createIdFromString,
449
- * id,
492
+ * type TestEvoluSchema,
450
493
  * NonEmptyTrimmedString100,
451
494
  * type Evolu,
495
+ * TestTodoId,
452
496
  * } from "@evolu/common";
453
497
  *
454
- * const TodoId = id("Todo");
455
- * type TodoId = typeof TodoId.Output;
456
- * const Schema = {
457
- * todo: { id: TodoId, title: NonEmptyTrimmedString100 },
458
- * };
459
- * const stableId = TodoId.orThrow(createIdFromString("my-todo-1"));
460
- * const upsertTodo = (evolu: Evolu<typeof Schema>) =>
498
+ * const stableId = TestTodoId.orThrow(createIdFromString("my-todo-1"));
499
+ * const upsertTodo = (evolu: Evolu<TestEvoluSchema>) =>
461
500
  * evolu.upsert("todo", {
462
501
  * id: stableId,
463
502
  * title: NonEmptyTrimmedString100.orThrow("Learn Evolu"),
464
503
  * }).id;
465
504
  *
466
- * assertType<ReturnType<typeof upsertTodo>, TodoId>();
505
+ * assertType<ReturnType<typeof upsertTodo>, TestTodoId>();
467
506
  * ```
468
507
  *
469
508
  * @see {@link Mutation}
470
509
  */
471
510
  readonly upsert: Mutation<S, "upsert">;
472
511
 
512
+ /**
513
+ * Returns the size of a mutation of `table` with `values`, measured as
514
+ * {@link maxMutationSize} describes.
515
+ *
516
+ * Use it to check values that column Types do not bound before mutating, or
517
+ * to show how much of the limit a mutation uses. It accepts any of the
518
+ * table's columns, so the values of an insert, update, or upsert fit, and it
519
+ * ignores `id` and `isDeleted`, which are not columns. Mutations of
520
+ * local-only tables are measured too, although they are exempt from the
521
+ * limit.
522
+ *
523
+ * ### Example
524
+ *
525
+ * ```ts
526
+ * import {
527
+ * assertType,
528
+ * type Evolu,
529
+ * maxMutationSize,
530
+ * type NonEmptyTrimmedString100,
531
+ * type TestEvoluSchema,
532
+ * } from "@evolu/common";
533
+ *
534
+ * const fitsTodo = (
535
+ * evolu: Evolu<TestEvoluSchema>,
536
+ * title: NonEmptyTrimmedString100,
537
+ * ) => evolu.getMutationSize("todo", { title }) <= maxMutationSize;
538
+ *
539
+ * assertType<ReturnType<typeof fitsTodo>, boolean>();
540
+ * ```
541
+ */
542
+ readonly getMutationSize: <TableName extends keyof S>(
543
+ table: TableName,
544
+ values: Partial<MutationValues<S[TableName], "update">>,
545
+ ) => PositiveInt;
546
+
473
547
  /**
474
548
  * Load {@link Query} and return a promise with {@link QueryRows}.
475
549
  *
476
- * The returned promise always resolves successfully because there is no
477
- * reason why loading should fail. All data are local, and the query is
478
- * typed.
550
+ * The returned promise always resolves successfully because all data are
551
+ * local and the query is typed. If the database refuses startup, unanswered
552
+ * loads stay pending until disposal resolves them with empty rows. Observe
553
+ * {@link EvoluErrorDep.evoluError} to display the refusal independently of
554
+ * query loading.
479
555
  *
480
556
  * Loading is batched. Returned promises are cached while pending and can be
481
- * reused after fulfillment until mutation-driven invalidation, which prevents
482
- * redundant database queries and supports React Suspense (stable references
483
- * while pending).
557
+ * reused after fulfillment, which prevents redundant database queries and
558
+ * supports React Suspense (stable references while pending). A mutation or
559
+ * incoming sync invalidates the cache of an unsubscribed query, so its next
560
+ * load reads again. A subscribed query keeps its cached rows and the
561
+ * subscription refreshes them, so a load can return rows that a pending
562
+ * refresh is about to replace.
484
563
  *
485
564
  * To subscribe a query for automatic updates, use
486
565
  * {@link Evolu.subscribeQuery}.
@@ -491,20 +570,17 @@ export interface Evolu<
491
570
  * import {
492
571
  * assertType,
493
572
  * createQueryBuilder,
494
- * id,
495
- * NonEmptyTrimmedString100,
573
+ * testEvoluSchema,
574
+ * type TestEvoluSchema,
496
575
  * type Evolu,
497
576
  * type QueryRows,
498
577
  * } from "@evolu/common";
499
578
  *
500
- * const Schema = {
501
- * todo: { id: id("Todo"), title: NonEmptyTrimmedString100 },
502
- * };
503
- * const createQuery = createQueryBuilder(Schema);
579
+ * const createQuery = createQueryBuilder(testEvoluSchema);
504
580
  * const allTodos = createQuery((db) =>
505
581
  * db.selectFrom("todo").selectAll(),
506
582
  * );
507
- * const loadTodos = async (evolu: Evolu<typeof Schema>) => {
583
+ * const loadTodos = async (evolu: Evolu<TestEvoluSchema>) => {
508
584
  * const rows = await evolu.loadQuery(allTodos);
509
585
  * return rows;
510
586
  * };
@@ -529,31 +605,22 @@ export interface Evolu<
529
605
  * ```ts
530
606
  * import {
531
607
  * assertType,
532
- * createIdFromString,
533
608
  * createQueryBuilder,
534
- * id,
535
- * NonEmptyTrimmedString100,
609
+ * testEvoluSchema,
610
+ * type TestEvoluSchema,
611
+ * testTodoId,
536
612
  * type Evolu,
537
613
  * type QueryRows,
538
614
  * } from "@evolu/common";
539
615
  *
540
- * const TodoId = id("Todo");
541
- * type TodoId = typeof TodoId.Output;
542
- * const Schema = {
543
- * todo: { id: TodoId, title: NonEmptyTrimmedString100 },
544
- * };
545
- * const createQuery = createQueryBuilder(Schema);
616
+ * const createQuery = createQueryBuilder(testEvoluSchema);
546
617
  * const allTodos = createQuery((db) =>
547
618
  * db.selectFrom("todo").select(["id", "title"]),
548
619
  * );
549
- * const todoById = (todoId: TodoId) =>
550
- * createQuery((db) =>
551
- * db.selectFrom("todo").select("title").where("id", "=", todoId),
552
- * );
553
- * const firstTodo = todoById(
554
- * TodoId.orThrow(createIdFromString("first-todo")),
620
+ * const firstTodo = createQuery((db) =>
621
+ * db.selectFrom("todo").select("title").where("id", "=", testTodoId),
555
622
  * );
556
- * const loadTodoQueries = (evolu: Evolu<typeof Schema>) =>
623
+ * const loadTodoQueries = (evolu: Evolu<TestEvoluSchema>) =>
557
624
  * evolu.loadQueries([allTodos, firstTodo]);
558
625
  *
559
626
  * assertType<
@@ -572,28 +639,28 @@ export interface Evolu<
572
639
  /**
573
640
  * Subscribe to {@link Query} {@link QueryRows} changes.
574
641
  *
642
+ * Cached rows invalidated before subscription are refreshed. Subscribing
643
+ * alone does not load a query that has no cached rows.
644
+ *
575
645
  * ### Example
576
646
  *
577
647
  * ```ts
578
648
  * import {
579
649
  * assertType,
580
650
  * createQueryBuilder,
581
- * id,
582
- * NonEmptyTrimmedString100,
651
+ * testEvoluSchema,
652
+ * type TestEvoluSchema,
583
653
  * type Evolu,
584
654
  * type QueryRows,
585
655
  * type Unsubscribe,
586
656
  * } from "@evolu/common";
587
657
  *
588
- * const Schema = {
589
- * todo: { id: id("Todo"), title: NonEmptyTrimmedString100 },
590
- * };
591
- * const createQuery = createQueryBuilder(Schema);
658
+ * const createQuery = createQueryBuilder(testEvoluSchema);
592
659
  * const allTodos = createQuery((db) =>
593
660
  * db.selectFrom("todo").select("title"),
594
661
  * );
595
662
  * const subscribeToTodos = (
596
- * evolu: Evolu<typeof Schema>,
663
+ * evolu: Evolu<TestEvoluSchema>,
597
664
  * onRows: (rows: QueryRows<typeof allTodos.Row>) => void,
598
665
  * ) =>
599
666
  * evolu.subscribeQuery(allTodos)(() => {
@@ -616,20 +683,17 @@ export interface Evolu<
616
683
  * import {
617
684
  * assertType,
618
685
  * createQueryBuilder,
619
- * id,
620
- * NonEmptyTrimmedString100,
686
+ * testEvoluSchema,
687
+ * type TestEvoluSchema,
621
688
  * type Evolu,
622
689
  * type QueryRows,
623
690
  * } from "@evolu/common";
624
691
  *
625
- * const Schema = {
626
- * todo: { id: id("Todo"), title: NonEmptyTrimmedString100 },
627
- * };
628
- * const createQuery = createQueryBuilder(Schema);
692
+ * const createQuery = createQueryBuilder(testEvoluSchema);
629
693
  * const allTodos = createQuery((db) =>
630
694
  * db.selectFrom("todo").select("title"),
631
695
  * );
632
- * const getTodos = (evolu: Evolu<typeof Schema>) =>
696
+ * const getTodos = (evolu: Evolu<TestEvoluSchema>) =>
633
697
  * evolu.getQueryRows(allTodos);
634
698
  *
635
699
  * assertType<
@@ -647,7 +711,8 @@ export interface Evolu<
647
711
  * of starting parallel exports.
648
712
  *
649
713
  * The pending promise rejects if this {@link Evolu} instance is disposed
650
- * before export completion.
714
+ * before export completion. If the database refuses startup, export stays
715
+ * pending until disposal; see {@link Evolu.loadQuery}.
651
716
  */
652
717
  readonly exportDatabase: () => Promise<Uint8Array<ArrayBuffer>>;
653
718
 
@@ -703,21 +768,15 @@ export interface Evolu<
703
768
  * ```ts
704
769
  * import {
705
770
  * assertEqual,
706
- * createAppOwner,
707
771
  * createOwnerWebSocketTransport,
708
- * createOwnerSecret,
709
- * createRandomBytes,
710
772
  * deriveShardOwner,
773
+ * testAppOwner,
711
774
  * type Evolu,
712
775
  * type ReadonlyOwner,
713
776
  * type UnuseOwner,
714
777
  * } from "@evolu/common";
715
778
  *
716
- * // Create once, persist the mnemonic securely, and restore it on later runs.
717
- * const appOwner = createAppOwner(
718
- * createOwnerSecret({ randomBytes: createRandomBytes() }),
719
- * );
720
- * const shardOwner = deriveShardOwner(appOwner, ["todos", 1]);
779
+ * const shardOwner = deriveShardOwner(testAppOwner, ["todos", 1]);
721
780
  * const shardTransport = createOwnerWebSocketTransport({
722
781
  * url: "wss://relay.example.com",
723
782
  * ownerId: shardOwner.id,
@@ -753,17 +812,168 @@ export interface Evolu<
753
812
  owner: ReadonlyOwner | Owner,
754
813
  transports?: NonEmptyReadonlyArray<OwnerTransport>,
755
814
  ) => UnuseOwner;
815
+
816
+ /**
817
+ * Requests a new synchronization round for an active {@link OwnerId}.
818
+ *
819
+ * Reconciles locally stored changes, including writes previously rejected
820
+ * with {@link ProtocolQuotaError}, through the owner's active transports. Call
821
+ * this after the relay provider confirms additional quota is available.
822
+ * Existing connections and {@link Evolu.useOwner} registrations are retained,
823
+ * including registrations shared by multiple instances or tabs.
824
+ *
825
+ * The owner must have an active writable registration in this database.
826
+ * Unregistered or read-only owners are ignored. Requests are skipped while
827
+ * all of the owner's transports are closed; they synchronize when they
828
+ * reopen. Disposal drops locally buffered requests. Calling a disposed
829
+ * instance throws, like other Evolu operations.
830
+ *
831
+ * Returns immediately, without waiting for synchronization to complete.
832
+ * Errors are reported through {@link EvoluErrorDep.evoluError}.
833
+ *
834
+ * ### Example
835
+ *
836
+ * ```ts
837
+ * import { assertType, type Evolu, type OwnerId } from "@evolu/common";
838
+ *
839
+ * // Call after successfully increasing the affected owner's relay quota.
840
+ * const onQuotaIncreased = (evolu: Evolu, ownerId: OwnerId) => {
841
+ * evolu.requestSync(ownerId);
842
+ * };
843
+ *
844
+ * assertType<
845
+ * typeof onQuotaIncreased,
846
+ * (evolu: Evolu, ownerId: OwnerId) => void
847
+ * >();
848
+ * ```
849
+ */
850
+ readonly requestSync: (ownerId: OwnerId) => void;
756
851
  }
757
852
 
758
- /** Function returned by {@link Evolu.useOwner} to stop using an Owner for sync. */
853
+ /**
854
+ * Function returned by {@link Evolu.useOwner} to stop using an Owner for sync.
855
+ *
856
+ * @group Core
857
+ */
759
858
  export type UnuseOwner = () => void;
760
859
 
860
+ /**
861
+ * Represents errors that can occur in {@link Evolu}.
862
+ *
863
+ * Apps show them from {@link EvoluErrorDep.evoluError}.
864
+ *
865
+ * An error that leaves the app unusable deserves a modal dialog, which moves
866
+ * focus into itself and restores it when closed. Any other error is a status
867
+ * message: show it in a region with `role="alert"`, which screen readers
868
+ * announce without moving focus, so the user keeps working and a background tab
869
+ * shows it when the user returns. Avoid `alert()`, which blocks the page, once
870
+ * in every open tab.
871
+ *
872
+ * - {@link UnsupportedDbVersionError} blocks the app: the local data needs a newer
873
+ * version of it. Ask the user to update the app or close all its tabs.
874
+ * - {@link OtherBuildRunningError} blocks the app while it lasts: another version
875
+ * of the app holds the local data. Ask the user to close the app's other
876
+ * tabs. It clears when the wait ends.
877
+ * - {@link ProtocolError} does not block the app: sync with a relay failed for an
878
+ * owner, because the relay rejected or failed a request, or sent data that
879
+ * could not be decoded or verified; sync state shows the affected routes. A
880
+ * {@link ProtocolQuotaError} needs more relay quota, then
881
+ * {@link Evolu.requestSync}; a {@link ProtocolVersionError} needs an app or
882
+ * relay update.
883
+ * - {@link StorageQuotaError} does not block the app: a storage or billing quota
884
+ * was exceeded, so a batch of an owner's changes was not stored. The built-in
885
+ * client storage does not report it yet; a relay's quota arrives as
886
+ * {@link ProtocolQuotaError}.
887
+ * - {@link DecryptWithXChaCha20Poly1305Error} does not block the app: changes
888
+ * received for an owner could not be decrypted, so none of their batch was
889
+ * stored.
890
+ * - {@link UnknownError} does not block the app: Evolu logged an unexpected
891
+ * failure. Show a generic message.
892
+ *
893
+ * @group Core
894
+ */
895
+ export type EvoluError =
896
+ | DecryptWithXChaCha20Poly1305Error
897
+ | OtherBuildRunningError
898
+ | ProtocolError
899
+ | StorageQuotaError
900
+ | UnknownError
901
+ | UnsupportedDbVersionError;
902
+
903
+ /**
904
+ * The largest {@link Mutation}, in bytes.
905
+ *
906
+ * A mutation's size is its change as encoded for sync: the table name, the ID,
907
+ * and every column name and value, with a few bytes of overhead. A string takes
908
+ * at most three bytes per UTF-16 code unit. Every mutation within the limit
909
+ * fits one protocol message, so it can always sync. A larger mutation throws
910
+ * and is not saved. Mutations of local-only tables are exempt, because they
911
+ * never sync. Measure a mutation in advance with {@link Evolu.getMutationSize}.
912
+ *
913
+ * @group Core
914
+ */
915
+ export const maxMutationSize: PositiveInt =
916
+ /*#__PURE__*/ PositiveInt.orThrow(640_000);
917
+
918
+ /** Measures a mutation as {@link maxMutationSize} describes. */
919
+ const measureMutation = (
920
+ table: string,
921
+ values: ReadonlyRecord<string, SqliteValue | undefined>,
922
+ ): PositiveInt => {
923
+ const columns = createMutableRecord<string, SqliteValue>();
924
+ for (const [column, value] of objectToEntries(values)) {
925
+ if (value !== undefined && column !== "id" && column !== "isDeleted") {
926
+ columns[column] = value;
927
+ }
928
+ }
929
+
930
+ // Measuring with the protocol's encoding keeps the limit and the message size
931
+ // from disagreeing. A separate formula would save the tab about 3 KB
932
+ // compressed, but it would be a second encoding to keep in sync, and it
933
+ // would have to overcount.
934
+ const buffer = createBuffer();
935
+ encodeDbChange(buffer, {
936
+ // Every timestamp and ID takes 16 bytes, and the flags always take one.
937
+ timestamp: createTimestamp(),
938
+ change: DbChange.orThrow({
939
+ table,
940
+ id: mutationSizeId,
941
+ values: columns,
942
+ isInsert: true,
943
+ isDelete: null,
944
+ }),
945
+ });
946
+ return buffer.getLength() as PositiveInt;
947
+ };
948
+
949
+ const mutationSizeId = /*#__PURE__*/ Id.orThrow("A".repeat(22));
950
+
951
+ /**
952
+ * Dependency wrapper for the shared {@link EvoluError} store.
953
+ *
954
+ * @group Construction
955
+ */
761
956
  export interface EvoluErrorDep {
762
957
  /**
763
958
  * {@link ReadonlyStore} of {@link EvoluError} shared by all {@link Evolu}
764
959
  * instances created from the same {@link createEvoluDeps} result.
765
960
  *
961
+ * Starts at `null` and otherwise holds the latest reported error until the
962
+ * first {@link UnsupportedDbVersionError}. That refusal remains for the
963
+ * lifetime of these dependencies, even if a tenant is disposed and recreated.
964
+ * Later errors are still logged but do not replace it or notify this store's
965
+ * subscribers. Fresh dependencies start with a fresh error store. An
966
+ * {@link OtherBuildRunningError} reports a wait, so the store returns to
967
+ * `null` when the wait ends, unless another error replaced it.
968
+ *
766
969
  * Subscribe once to show user-facing error messages across all instances.
970
+ * While a refused database's tenant remains alive, the SharedWorker sends the
971
+ * refusal to each tab once, including tabs that connect later, and starts no
972
+ * replacement database workers. After all instances release that tenant and
973
+ * it is disposed when idle, creating another instance retries startup and may
974
+ * send the refusal again. On the web, a refused tab first reloads once
975
+ * instead; see {@link UnsupportedDbVersionError}. Show that blocking message
976
+ * outside any query-loading boundary, so pending queries do not hide it.
767
977
  *
768
978
  * ### Example
769
979
  *
@@ -771,60 +981,113 @@ export interface EvoluErrorDep {
771
981
  * import {
772
982
  * assertEqual,
773
983
  * createStore,
774
- * Millis,
775
984
  * type EvoluError,
776
985
  * } from "@evolu/common";
777
986
  * import type { EvoluErrorDep } from "@evolu/common/local-first";
778
987
  *
988
+ * // The message for the current error, or null for none.
989
+ * const errorMessage = (error: EvoluError | null): string | null => {
990
+ * if (!error) return null;
991
+ * // oxlint-disable-next-line typescript/switch-exhaustiveness-check -- The default handles every other EvoluError.
992
+ * switch (error.type) {
993
+ * case "UnsupportedDbVersionError":
994
+ * return "Your data requires a newer version of this app. Please update it.";
995
+ * case "OtherBuildRunningError":
996
+ * return "This app is open in another tab with a different version. Close that tab to continue.";
997
+ * default:
998
+ * return "Something went wrong. Please try again.";
999
+ * }
1000
+ * };
1001
+ *
779
1002
  * // Stand-in for run.deps.evoluError from createEvoluDeps.
780
1003
  * using evoluError = createStore<EvoluError | null>(null);
781
1004
  * const deps = { evoluError } satisfies EvoluErrorDep;
782
- * let displayedMessage = "";
783
- * const showMessage = (message: string) => {
784
- * displayedMessage = message;
785
- * };
1005
+ * // What the app showed over time; null hides the message.
1006
+ * const shown: Array<string | null> = [];
786
1007
  *
787
1008
  * deps.evoluError.subscribe(() => {
788
- * const error = deps.evoluError.get();
789
- * if (!error) return;
790
- *
791
- * // oxlint-disable-next-line typescript/switch-exhaustiveness-check -- The default intentionally handles every other EvoluError.
792
- * switch (error.type) {
793
- * case "TimestampDriftError":
794
- * // Show guidance specific to the detected error.
795
- * showMessage(
796
- * "Your system clock appears incorrect. Please fix it.",
797
- * );
798
- * break;
799
- * default:
800
- * // Show a generic user message for other operational errors.
801
- * showMessage("Something went wrong. Please try again.");
802
- * }
1009
+ * shown.push(errorMessage(deps.evoluError.get()));
803
1010
  * });
804
1011
  *
805
- * deps.evoluError.set({
806
- * type: "TimestampDriftError",
807
- * next: Millis.orThrow(360000),
808
- * now: Millis.orThrow(0),
809
- * });
810
- * assertEqual(
811
- * displayedMessage,
812
- * "Your system clock appears incorrect. Please fix it.",
813
- * );
1012
+ * // Another version keeps this tab waiting, then the wait ends.
1013
+ * deps.evoluError.set({ type: "OtherBuildRunningError" });
1014
+ * deps.evoluError.set(null);
1015
+ *
1016
+ * assertEqual(shown, [
1017
+ * "This app is open in another tab with a different version. Close that tab to continue.",
1018
+ * null,
1019
+ * ]);
814
1020
  * ```
815
1021
  */
816
1022
  readonly evoluError: ReadonlyStore<EvoluError | null>;
817
1023
  }
818
1024
 
1025
+ /**
1026
+ * Dependency wrapper for the shared {@link SyncState} store.
1027
+ *
1028
+ * @group Construction
1029
+ */
1030
+ export interface SyncStateDep {
1031
+ /**
1032
+ * {@link ReadonlyStore} of the latest {@link SyncState} shared by all
1033
+ * {@link Evolu} instances, or null before the shared worker sends its first
1034
+ * snapshot. Derive what to show from it, such as one indicator per relay, or
1035
+ * use {@link syncStateToOwnerSyncStates} for one state per owner.
1036
+ *
1037
+ * ### Example
1038
+ *
1039
+ * ```ts
1040
+ * import {
1041
+ * assertEqual,
1042
+ * createId,
1043
+ * createStore,
1044
+ * testCreateDeps,
1045
+ * } from "@evolu/common";
1046
+ * import type {
1047
+ * SyncState,
1048
+ * SyncStateDep,
1049
+ * } from "@evolu/common/local-first";
1050
+ *
1051
+ * const openRelayLabels = (deps: SyncStateDep): ReadonlyArray<string> =>
1052
+ * (deps.syncState.get()?.transports ?? [])
1053
+ * .filter(({ readyState }) => readyState === "open")
1054
+ * .map(({ label }) => label);
1055
+ *
1056
+ * using syncState = createStore<SyncState | null>(null);
1057
+ * assertEqual(openRelayLabels({ syncState }), []);
1058
+ *
1059
+ * const deps = testCreateDeps();
1060
+ * syncState.set({
1061
+ * transports: [
1062
+ * {
1063
+ * id: createId<"SyncTransport">(deps),
1064
+ * label: "wss://relay.example",
1065
+ * readyState: "open",
1066
+ * openedAt: null,
1067
+ * closedAt: null,
1068
+ * error: null,
1069
+ * },
1070
+ * ],
1071
+ * tenants: [],
1072
+ * });
1073
+ * assertEqual(openRelayLabels({ syncState }), ["wss://relay.example"]);
1074
+ * ```
1075
+ */
1076
+ readonly syncState: ReadonlyStore<SyncState | null>;
1077
+ }
1078
+
819
1079
  /**
820
1080
  * Shared platform dependencies for creating {@link Evolu} instances.
821
1081
  *
822
1082
  * Includes platform adapters, the shared {@link EvoluErrorDep.evoluError} store,
823
1083
  * and disposal for owned resources.
1084
+ *
1085
+ * @group Construction
824
1086
  */
825
1087
  export type EvoluDeps = EvoluPlatformDeps &
826
1088
  ConsoleDep &
827
1089
  EvoluErrorDep &
1090
+ SyncStateDep &
828
1091
  Disposable;
829
1092
 
830
1093
  /**
@@ -832,6 +1095,8 @@ export type EvoluDeps = EvoluPlatformDeps &
832
1095
  *
833
1096
  * Provides worker and channel adapters plus optional platform integrations for
834
1097
  * logging and synchronous UI flush.
1098
+ *
1099
+ * @group Construction
835
1100
  */
836
1101
  export type EvoluPlatformDeps = CreateDbWorkerDep &
837
1102
  CreateBroadcastChannelDep &
@@ -848,36 +1113,50 @@ export type EvoluPlatformDeps = CreateDbWorkerDep &
848
1113
  *
849
1114
  * Call this once per platform and reuse the returned deps when creating
850
1115
  * multiple Evolu instances. The returned deps object owns long-lived resources
851
- * such as worker channels and the shared {@link EvoluErrorDep.evoluError}
852
- * store.
1116
+ * such as worker channels and the shared {@link EvoluErrorDep.evoluError} and
1117
+ * {@link SyncStateDep.syncState} stores.
853
1118
  *
854
1119
  * Dispose it only during app shutdown.
1120
+ *
1121
+ * @group Construction
855
1122
  */
856
1123
  export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
857
1124
  const { createBroadcastChannel, sharedWorker } = deps;
858
1125
  const console = deps.console ?? createConsole();
1126
+ // Opened when the worker connects.
1127
+ let syncStateBroadcastChannel: BroadcastChannel<SyncState> | null = null;
1128
+ let tabLeaderElection: Disposable | null = null;
859
1129
 
860
1130
  using disposer = new DisposableStack();
861
1131
  disposer.use(sharedWorker);
862
1132
  const evoluError = disposer.use(createStore<EvoluError | null>(null));
1133
+ const syncState = disposer.use(createStore<SyncState | null>(null));
1134
+ disposer.defer(() => {
1135
+ syncStateBroadcastChannel?.[Symbol.dispose]();
1136
+ });
863
1137
 
864
1138
  const consoleEntryOrErrorBroadcastChannel = disposer.use(
865
1139
  createBroadcastChannel<ConsoleEntryOrError>(
866
1140
  consoleEntryOrErrorBroadcastChannelName,
867
1141
  ),
868
1142
  );
1143
+ const setEvoluError = (error: EvoluError): void => {
1144
+ if (evoluError.get()?.type === "UnsupportedDbVersionError") return;
1145
+ evoluError.set(error);
1146
+ };
1147
+
869
1148
  consoleEntryOrErrorBroadcastChannel.onMessage = (message) => {
870
1149
  switch (message.type) {
871
1150
  case "ConsoleEntry":
872
1151
  console.write(message.entry);
873
1152
  // Fallback channel for unexpected errors without EvoluError typing.
874
1153
  if (message.entry.method === "error") {
875
- evoluError.set(createUnknownError(message.entry.args));
1154
+ setEvoluError(createUnknownError(message.entry.args));
876
1155
  }
877
1156
  break;
878
1157
 
879
1158
  case "Error":
880
- evoluError.set(message.error);
1159
+ setEvoluError(message.error);
881
1160
  // Keep typed errors visible in logs as operational failures.
882
1161
  console.error(message.error);
883
1162
  break;
@@ -888,23 +1167,69 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
888
1167
  };
889
1168
 
890
1169
  sharedWorker.port.onMessage = (message) => {
891
- deps.createDbWorker().postMessage(message, [message.port]);
1170
+ switch (message.type) {
1171
+ case "DbWorkerInit":
1172
+ deps.createDbWorker().postMessage(message, [message.port]);
1173
+ break;
1174
+
1175
+ case "Error":
1176
+ // Sent to this tab only: its database refused startup, or its worker
1177
+ // still waits for another build.
1178
+ setEvoluError(message.error);
1179
+ console.error(message.error);
1180
+ break;
1181
+
1182
+ case "Waiting":
1183
+ case "StorageUnavailable":
1184
+ // Platform adapters act on these; see Builds and Storage in the Shared
1185
+ // module.
1186
+ break;
1187
+
1188
+ case "Connected": {
1189
+ assert(!syncStateBroadcastChannel, "The shared worker connects once.");
1190
+ // The wait the error reported is over.
1191
+ if (evoluError.get()?.type === "OtherBuildRunningError") {
1192
+ evoluError.set(null);
1193
+ }
1194
+ // The worker is the channel's only sender, and one sender's messages
1195
+ // arrive in order, so the last one is current.
1196
+ syncStateBroadcastChannel = createBroadcastChannel<SyncState>(
1197
+ message.syncStateChannelName,
1198
+ );
1199
+ syncStateBroadcastChannel.onMessage = (state) => {
1200
+ syncState.set(state);
1201
+ };
1202
+ // Asking only after listening misses no snapshot.
1203
+ sharedWorker.port.postMessage({ type: "RequestSyncState" });
1204
+ // Only this worker's tabs compete, so a tab of another worker never
1205
+ // hosts its DbWorkers.
1206
+ tabLeaderElection = acquireLeaderLockCallback(deps)(
1207
+ `tab-${message.workerId}`,
1208
+ () => {
1209
+ sharedWorker.port.postMessage({
1210
+ type: "AnnounceTabLeader",
1211
+ consoleLevel: console.getLevel(),
1212
+ });
1213
+ },
1214
+ );
1215
+ break;
1216
+ }
1217
+
1218
+ default:
1219
+ exhaustiveCheck(message);
1220
+ }
892
1221
  };
893
1222
 
894
- disposer.use(
895
- acquireLeaderLockCallback(deps)("tab", () => {
896
- sharedWorker.port.postMessage({
897
- type: "AnnounceTabLeader",
898
- consoleLevel: console.getLevel(),
899
- });
900
- }),
901
- );
1223
+ disposer.defer(() => {
1224
+ tabLeaderElection?.[Symbol.dispose]();
1225
+ });
902
1226
 
903
1227
  return disposable<EvoluDeps>(
904
1228
  {
905
1229
  ...deps,
906
1230
  console,
907
1231
  evoluError,
1232
+ syncState,
908
1233
  },
909
1234
  disposer,
910
1235
  );
@@ -913,6 +1238,8 @@ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
913
1238
  /**
914
1239
  * Creates an {@link Evolu} instance from {@link EvoluSchema} and
915
1240
  * {@link EvoluConfig}.
1241
+ *
1242
+ * @group Construction
916
1243
  */
917
1244
  export const createEvolu =
918
1245
  <S extends EvoluSchema>(
@@ -1091,10 +1418,7 @@ export const createEvolu =
1091
1418
  case "RefreshQueries": {
1092
1419
  releaseUnsubscribedLoadingPromises();
1093
1420
 
1094
- const queries = new Set<Query>([
1095
- ...loadingPromisesByQuery.keys(),
1096
- ...subscribedQueriesRefCount.keys(),
1097
- ]);
1421
+ const queries = new Set<Query>(subscribedQueriesRefCount.keys());
1098
1422
 
1099
1423
  if (isNonEmptySet(queries)) postMessage({ type: "Query", queries });
1100
1424
  break;
@@ -1202,6 +1526,41 @@ export const createEvolu =
1202
1526
  `Invalid DbChange for table '${String(table)}'.`,
1203
1527
  );
1204
1528
 
1529
+ // Copy binary values, so what is saved is what was passed: the caller
1530
+ // can change a Uint8Array before the batch is sent, a view of a
1531
+ // resizable buffer can grow, and a view of a shared buffer cannot be
1532
+ // sent to a SharedWorker at all. Node's Buffer passes validation and
1533
+ // its slice shares memory, so only the constructor copies reliably.
1534
+ for (const [column, value] of objectToEntries(dbChange.values)) {
1535
+ if (typeof value === "object" && value !== null) {
1536
+ changeValues[column] = new Uint8Array(value);
1537
+ }
1538
+ }
1539
+
1540
+ // A change over maxMutationSize could never sync, and a shared worker
1541
+ // hosting two databases would stop all work of that database. Correct
1542
+ // apps bound column Types, so it is a programmer error and throws like
1543
+ // the DbChange check above, before batching: it gets no timestamp, the
1544
+ // database worker never sees it, and the code after the call does not
1545
+ // run, so a form keeps its input and no row refers to the missing one.
1546
+ //
1547
+ // Rejected: reporting it through evoluError, which lets that code run
1548
+ // as if the mutation was saved; checking in the database worker, which
1549
+ // gets it already batched and cannot reach the call site; quarantine,
1550
+ // which holds changes stored for sync; and skipping it in sync, which
1551
+ // keeps range fingerprints disagreeing. Received changes are not
1552
+ // checked, and oversized changes stored before this limit are not
1553
+ // recovered, which needs a history-aware design.
1554
+ //
1555
+ // Local-only tables never sync, so they have no limit.
1556
+ if (!isLocalOnlyTable(dbChange.table)) {
1557
+ const size = measureMutation(dbChange.table, dbChange.values);
1558
+ assert(
1559
+ size <= maxMutationSize,
1560
+ `The mutation of table '${dbChange.table}' is ${size} bytes, over maxMutationSize (${maxMutationSize}). Bound the Types of its columns or check it with evolu.getMutationSize.`,
1561
+ );
1562
+ }
1563
+
1205
1564
  mutateBatch.push({
1206
1565
  change: { ...dbChange, ownerId: options?.ownerId ?? appOwner.id },
1207
1566
  onComplete: options?.onComplete,
@@ -1290,6 +1649,12 @@ export const createEvolu =
1290
1649
  update: createMutation("update"),
1291
1650
  upsert: createMutation("upsert"),
1292
1651
 
1652
+ getMutationSize: (table, values) =>
1653
+ measureMutation(
1654
+ String(table),
1655
+ values as ReadonlyRecord<string, SqliteValue | undefined>,
1656
+ ),
1657
+
1293
1658
  loadQuery,
1294
1659
  loadQueries: <Q extends Queries<S>>(
1295
1660
  queries: [...Q],
@@ -1311,6 +1676,27 @@ export const createEvolu =
1311
1676
  listener();
1312
1677
  });
1313
1678
 
1679
+ // Invalidation can happen after loading but before a framework
1680
+ // subscribes. Register first so subsequent invalidations also
1681
+ // refresh this query.
1682
+ const loadingPromise = loadingPromisesByQuery.get(query);
1683
+ if (loadingPromise?.releaseOnResolve) {
1684
+ // Retain the pending promise for its awaiters, but queue a read
1685
+ // after the mutation or sync that invalidated its result.
1686
+ loadingPromise.releaseOnResolve = false;
1687
+ queryBatch.push(query);
1688
+ } else if (!loadingPromise) {
1689
+ const rows = rowsByQueryMapStore.get().get(query);
1690
+ if (rows) {
1691
+ // Queue a read, but keep cached rows available to React use()
1692
+ // so a render before the worker responds does not suspend.
1693
+ void loadQuery(query);
1694
+ const entry = loadingPromisesByQuery.get(query);
1695
+ assertNotUndefined(entry);
1696
+ fulfillLoadingPromise(entry, rows);
1697
+ }
1698
+ }
1699
+
1314
1700
  return () => {
1315
1701
  assert(
1316
1702
  isSubscribed,
@@ -1347,6 +1733,13 @@ export const createEvolu =
1347
1733
  },
1348
1734
 
1349
1735
  useOwner,
1736
+ requestSync: (ownerId) => {
1737
+ // Do not let the forced mutation flush overtake pending owner actions.
1738
+ useOwnerBatch.flushNow();
1739
+ // Queue preceding mutations before the reconciliation request.
1740
+ mutateBatch.flushNow();
1741
+ useOwnerBatch.push({ action: "sync", ownerId });
1742
+ },
1350
1743
  },
1351
1744
  disposer,
1352
1745
  ),