@evolu/common 7.4.1 → 8.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +4 -61
  2. package/dist/src/Array.d.ts +343 -102
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +181 -85
  5. package/dist/src/Assert.d.ts +57 -11
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +47 -11
  8. package/dist/src/BigInt.d.ts +6 -1
  9. package/dist/src/BigInt.d.ts.map +1 -1
  10. package/dist/src/BigInt.js +5 -0
  11. package/dist/src/Brand.d.ts +40 -12
  12. package/dist/src/Brand.d.ts.map +1 -1
  13. package/dist/src/Brand.js +5 -0
  14. package/dist/src/Buffer.d.ts +6 -1
  15. package/dist/src/Buffer.d.ts.map +1 -1
  16. package/dist/src/Buffer.js +7 -2
  17. package/dist/src/Cache.d.ts +10 -2
  18. package/dist/src/Cache.d.ts.map +1 -1
  19. package/dist/src/Cache.js +6 -0
  20. package/dist/src/Callbacks.d.ts +10 -7
  21. package/dist/src/Callbacks.d.ts.map +1 -1
  22. package/dist/src/Callbacks.js +23 -13
  23. package/dist/src/Console.d.ts +361 -69
  24. package/dist/src/Console.d.ts.map +1 -1
  25. package/dist/src/Console.js +217 -117
  26. package/dist/src/Crypto.d.ts +70 -21
  27. package/dist/src/Crypto.d.ts.map +1 -1
  28. package/dist/src/Crypto.js +72 -27
  29. package/dist/src/Eq.d.ts +22 -2
  30. package/dist/src/Eq.d.ts.map +1 -1
  31. package/dist/src/Eq.js +21 -1
  32. package/dist/src/Error.d.ts +23 -12
  33. package/dist/src/Error.d.ts.map +1 -1
  34. package/dist/src/Error.js +27 -11
  35. package/dist/src/Function.d.ts +128 -56
  36. package/dist/src/Function.d.ts.map +1 -1
  37. package/dist/src/Function.js +118 -14
  38. package/dist/src/Identicon.d.ts +5 -0
  39. package/dist/src/Identicon.d.ts.map +1 -1
  40. package/dist/src/Identicon.js +6 -1
  41. package/dist/src/Lookup.d.ts +160 -0
  42. package/dist/src/Lookup.d.ts.map +1 -0
  43. package/dist/src/Lookup.js +192 -0
  44. package/dist/src/Microtask.d.ts +21 -0
  45. package/dist/src/Microtask.d.ts.map +1 -0
  46. package/dist/src/Microtask.js +37 -0
  47. package/dist/src/Number.d.ts +18 -4
  48. package/dist/src/Number.d.ts.map +1 -1
  49. package/dist/src/Number.js +23 -1
  50. package/dist/src/Object.d.ts +110 -5
  51. package/dist/src/Object.d.ts.map +1 -1
  52. package/dist/src/Object.js +108 -5
  53. package/dist/src/Option.d.ts +58 -0
  54. package/dist/src/Option.d.ts.map +1 -0
  55. package/dist/src/Option.js +43 -0
  56. package/dist/src/Order.d.ts +5 -0
  57. package/dist/src/Order.d.ts.map +1 -1
  58. package/dist/src/Order.js +8 -3
  59. package/dist/src/Platform.d.ts +36 -2
  60. package/dist/src/Platform.d.ts.map +1 -1
  61. package/dist/src/Platform.js +10 -6
  62. package/dist/src/Polyfills.d.ts +27 -0
  63. package/dist/src/Polyfills.d.ts.map +1 -0
  64. package/dist/src/Polyfills.js +299 -0
  65. package/dist/src/Random.d.ts +18 -7
  66. package/dist/src/Random.d.ts.map +1 -1
  67. package/dist/src/Random.js +9 -7
  68. package/dist/src/Redacted.d.ts +7 -4
  69. package/dist/src/Redacted.d.ts.map +1 -1
  70. package/dist/src/Redacted.js +5 -0
  71. package/dist/src/Ref.d.ts +31 -16
  72. package/dist/src/Ref.d.ts.map +1 -1
  73. package/dist/src/Ref.js +35 -7
  74. package/dist/src/RefCount.d.ts +62 -0
  75. package/dist/src/RefCount.d.ts.map +1 -0
  76. package/dist/src/RefCount.js +83 -0
  77. package/dist/src/Relation.d.ts +84 -29
  78. package/dist/src/Relation.d.ts.map +1 -1
  79. package/dist/src/Relation.js +83 -105
  80. package/dist/src/Resource.d.ts +263 -0
  81. package/dist/src/Resource.d.ts.map +1 -0
  82. package/dist/src/Resource.js +389 -0
  83. package/dist/src/Result.d.ts +390 -374
  84. package/dist/src/Result.d.ts.map +1 -1
  85. package/dist/src/Result.js +142 -70
  86. package/dist/src/Schedule.d.ts +953 -0
  87. package/dist/src/Schedule.d.ts.map +1 -0
  88. package/dist/src/Schedule.js +1199 -0
  89. package/dist/src/Set.d.ts +181 -0
  90. package/dist/src/Set.d.ts.map +1 -0
  91. package/dist/src/Set.js +137 -0
  92. package/dist/src/Skiplist.d.ts +6 -1
  93. package/dist/src/Skiplist.d.ts.map +1 -1
  94. package/dist/src/Skiplist.js +5 -0
  95. package/dist/src/Sqlite.d.ts +160 -60
  96. package/dist/src/Sqlite.d.ts.map +1 -1
  97. package/dist/src/Sqlite.js +290 -190
  98. package/dist/src/Store.d.ts +36 -28
  99. package/dist/src/Store.d.ts.map +1 -1
  100. package/dist/src/Store.js +55 -15
  101. package/dist/src/String.d.ts +5 -0
  102. package/dist/src/String.d.ts.map +1 -1
  103. package/dist/src/String.js +5 -0
  104. package/dist/src/Task.d.ts +2263 -423
  105. package/dist/src/Task.d.ts.map +1 -1
  106. package/dist/src/Task.js +1488 -348
  107. package/dist/src/Test.d.ts +122 -0
  108. package/dist/src/Test.d.ts.map +1 -0
  109. package/dist/src/Test.js +66 -0
  110. package/dist/src/Time.d.ts +201 -48
  111. package/dist/src/Time.d.ts.map +1 -1
  112. package/dist/src/Time.js +190 -86
  113. package/dist/src/Tracer.d.ts +48 -0
  114. package/dist/src/Tracer.d.ts.map +1 -0
  115. package/dist/src/Tracer.js +6 -0
  116. package/dist/src/Type.d.ts +582 -209
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +362 -240
  119. package/dist/src/Types.d.ts +160 -6
  120. package/dist/src/Types.d.ts.map +1 -1
  121. package/dist/src/Types.js +22 -2
  122. package/dist/src/WebSocket.d.ts +113 -58
  123. package/dist/src/WebSocket.d.ts.map +1 -1
  124. package/dist/src/WebSocket.js +210 -118
  125. package/dist/src/Worker.d.ts +245 -60
  126. package/dist/src/Worker.d.ts.map +1 -1
  127. package/dist/src/Worker.js +220 -64
  128. package/dist/src/index.d.ts +25 -3
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +19 -3
  131. package/dist/src/local-first/Db.d.ts +24 -213
  132. package/dist/src/local-first/Db.d.ts.map +1 -1
  133. package/dist/src/local-first/Db.js +622 -293
  134. package/dist/src/local-first/Error.d.ts +12 -0
  135. package/dist/src/local-first/Error.d.ts.map +1 -0
  136. package/dist/src/local-first/Error.js +6 -0
  137. package/dist/src/local-first/Evolu.d.ts +330 -285
  138. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  139. package/dist/src/local-first/Evolu.js +472 -402
  140. package/dist/src/local-first/LocalAuth.d.ts +10 -10
  141. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  142. package/dist/src/local-first/LocalAuth.js +6 -1
  143. package/dist/src/local-first/Owner.d.ts +70 -27
  144. package/dist/src/local-first/Owner.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.js +27 -14
  146. package/dist/src/local-first/Protocol.d.ts +58 -50
  147. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  148. package/dist/src/local-first/Protocol.js +253 -155
  149. package/dist/src/local-first/Query.d.ts +133 -43
  150. package/dist/src/local-first/Query.d.ts.map +1 -1
  151. package/dist/src/local-first/Query.js +139 -90
  152. package/dist/src/local-first/Relay.d.ts +18 -38
  153. package/dist/src/local-first/Relay.d.ts.map +1 -1
  154. package/dist/src/local-first/Relay.js +59 -171
  155. package/dist/src/local-first/Schema.d.ts +139 -144
  156. package/dist/src/local-first/Schema.d.ts.map +1 -1
  157. package/dist/src/local-first/Schema.js +78 -163
  158. package/dist/src/local-first/Shared.d.ts +125 -0
  159. package/dist/src/local-first/Shared.d.ts.map +1 -0
  160. package/dist/src/local-first/Shared.js +569 -0
  161. package/dist/src/local-first/Storage.d.ts +50 -53
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +121 -173
  164. package/dist/src/local-first/Timestamp.d.ts +28 -35
  165. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  166. package/dist/src/local-first/Timestamp.js +24 -25
  167. package/dist/src/local-first/index.d.ts +2 -15
  168. package/dist/src/local-first/index.d.ts.map +1 -1
  169. package/dist/src/local-first/index.js +2 -15
  170. package/package.json +36 -26
  171. package/src/Array.ts +467 -106
  172. package/src/Assert.ts +87 -12
  173. package/src/BigInt.ts +7 -1
  174. package/src/Brand.ts +41 -12
  175. package/src/Buffer.ts +10 -4
  176. package/src/Cache.ts +11 -2
  177. package/src/Callbacks.ts +33 -23
  178. package/src/Console.ts +567 -178
  179. package/src/Crypto.ts +111 -62
  180. package/src/Eq.ts +30 -3
  181. package/src/Error.ts +50 -34
  182. package/src/Function.ts +140 -68
  183. package/src/Identicon.ts +7 -1
  184. package/src/Lookup.ts +415 -0
  185. package/src/Microtask.ts +58 -0
  186. package/src/Number.ts +40 -7
  187. package/src/Object.ts +155 -16
  188. package/src/Option.ts +74 -0
  189. package/src/Order.ts +11 -3
  190. package/src/Platform.ts +43 -7
  191. package/src/Polyfills.ts +465 -0
  192. package/src/Random.ts +25 -11
  193. package/src/Redacted.ts +8 -4
  194. package/src/Ref.ts +72 -21
  195. package/src/RefCount.ts +170 -0
  196. package/src/Relation.ts +188 -136
  197. package/src/Resource.ts +864 -0
  198. package/src/Result.ts +548 -381
  199. package/src/Schedule.ts +1471 -0
  200. package/src/Set.ts +247 -0
  201. package/src/Skiplist.ts +7 -1
  202. package/src/Sqlite.ts +409 -304
  203. package/src/Store.ts +94 -48
  204. package/src/String.ts +6 -0
  205. package/src/Task.ts +3685 -740
  206. package/src/Test.ts +162 -0
  207. package/src/Time.ts +348 -145
  208. package/src/Tracer.ts +54 -0
  209. package/src/Type.ts +851 -430
  210. package/src/Types.ts +202 -7
  211. package/src/WebSocket.ts +311 -178
  212. package/src/Worker.ts +527 -147
  213. package/src/index.ts +74 -3
  214. package/src/local-first/Db.ts +871 -597
  215. package/src/local-first/Error.ts +17 -0
  216. package/src/local-first/Evolu.ts +884 -824
  217. package/src/local-first/LocalAuth.ts +10 -10
  218. package/src/local-first/Owner.ts +94 -42
  219. package/src/local-first/Protocol.ts +440 -303
  220. package/src/local-first/Query.ts +243 -194
  221. package/src/local-first/Relay.ts +91 -252
  222. package/src/local-first/Schema.ts +284 -394
  223. package/src/local-first/Shared.ts +932 -0
  224. package/src/local-first/Storage.ts +206 -277
  225. package/src/local-first/Timestamp.ts +38 -45
  226. package/src/local-first/index.ts +2 -16
  227. package/LICENSE +0 -21
  228. package/dist/src/Instances.d.ts +0 -34
  229. package/dist/src/Instances.d.ts.map +0 -1
  230. package/dist/src/Instances.js +0 -44
  231. package/dist/src/Resources.d.ts +0 -118
  232. package/dist/src/Resources.d.ts.map +0 -1
  233. package/dist/src/Resources.js +0 -197
  234. package/dist/src/local-first/Platform.d.ts +0 -25
  235. package/dist/src/local-first/Platform.d.ts.map +0 -1
  236. package/dist/src/local-first/Platform.js +0 -1
  237. package/dist/src/local-first/Public.d.ts +0 -22
  238. package/dist/src/local-first/Public.d.ts.map +0 -1
  239. package/dist/src/local-first/Public.js +0 -15
  240. package/dist/src/local-first/PublicKysely.d.ts +0 -148
  241. package/dist/src/local-first/PublicKysely.d.ts.map +0 -1
  242. package/dist/src/local-first/PublicKysely.js +0 -184
  243. package/dist/src/local-first/Sync.d.ts +0 -112
  244. package/dist/src/local-first/Sync.d.ts.map +0 -1
  245. package/dist/src/local-first/Sync.js +0 -526
  246. package/src/Instances.ts +0 -92
  247. package/src/Resources.ts +0 -367
  248. package/src/local-first/Platform.ts +0 -27
  249. package/src/local-first/Public.ts +0 -42
  250. package/src/local-first/PublicKysely.ts +0 -239
  251. package/src/local-first/Sync.ts +0 -949
@@ -1,80 +1,199 @@
1
- import { pack } from "msgpackr";
1
+ /**
2
+ * Local-first platform.
3
+ *
4
+ * @module
5
+ */
6
+
2
7
  import {
3
- dedupeArray,
8
+ emptyArray,
4
9
  isNonEmptyArray,
5
- isNonEmptyReadonlyArray,
10
+ mapArray,
11
+ type NonEmptyReadonlyArray,
6
12
  } from "../Array.js";
7
- import { assert, assertNonEmptyReadonlyArray } from "../Assert.js";
8
- import { createCallbacks } from "../Callbacks.js";
9
- import { ConsoleDep } from "../Console.js";
10
- import { RandomBytesDep, SymmetricCryptoDecryptError } from "../Crypto.js";
11
- import { eqArrayNumber } from "../Eq.js";
12
- import { TransferableError } from "../Error.js";
13
- import { exhaustiveCheck } from "../Function.js";
14
- import { createInstances, Instances } from "../Instances.js";
15
- import { err, ok, Result } from "../Result.js";
16
13
  import {
17
- isSqlMutation,
18
- SafeSql,
19
- SqliteBoolean,
20
- sqliteBooleanToBoolean,
21
- SqliteError,
22
- SqliteQuery,
23
- } from "../Sqlite.js";
24
- import { createStore, StoreSubscribe } from "../Store.js";
25
- import { TimeDep } from "../Time.js";
14
+ assert,
15
+ assertNonEmptyReadonlyArray,
16
+ assertNotDisposed,
17
+ } from "../Assert.js";
18
+ import { createCallbacks } from "../Callbacks.js";
19
+ import type { ConsoleDep } from "../Console.js";
20
+ import { createConsole } from "../Console.js";
21
+ import { createUnknownError } from "../Error.js";
22
+ import { exhaustiveCheck, todo } from "../Function.js";
23
+ import { createMicrotaskBatch } from "../Microtask.js";
24
+ import type { FlushSyncDep, ReloadAppDep } from "../Platform.js";
25
+ import { createRefCountByKey } from "../RefCount.js";
26
+ import { err, ok } from "../Result.js";
27
+ import { isNonEmptySet } from "../Set.js";
28
+ import { SqliteBoolean, sqliteBooleanToBoolean } from "../Sqlite.js";
29
+ import type { Listener, ReadonlyStore, Unsubscribe } from "../Store.js";
30
+ import { createStore } from "../Store.js";
31
+ import { type createRun, type Task } from "../Task.js";
32
+ import type { Id, TypeError } from "../Type.js";
26
33
  import {
34
+ brand,
27
35
  createId,
28
- Id,
29
- InferErrors,
30
- InferInput,
31
- InferType,
32
- Mnemonic,
33
- ObjectType,
34
- SimpleName,
35
- ValidMutationSize,
36
- ValidMutationSizeError,
36
+ createIdFromString,
37
+ Name,
38
+ UrlSafeString,
37
39
  } from "../Type.js";
38
- import { IntentionalNever } from "../Types.js";
39
- import { CreateDbWorkerDep, DbConfig, defaultDbConfig } from "./Db.js";
40
- import { AppOwner } from "./Owner.js";
41
- import { FlushSyncDep, ReloadAppDep } from "./Platform.js";
42
- import { ProtocolError } from "./Protocol.js";
43
- import {
44
- applyPatches,
45
- createSubscribedQueries,
46
- emptyRows,
40
+ import type { ExtractType } from "../Types.js";
41
+ import type { CreateMessageChannelDep } from "../Worker.js";
42
+ import type { CreateDbWorkerDep } from "./Db.js";
43
+ import type { EvoluError } from "./Error.js";
44
+ import type {
45
+ AppOwner,
46
+ Owner,
47
+ OwnerId,
48
+ OwnerTransport,
49
+ ReadonlyOwner,
50
+ SyncOwner,
51
+ } from "./Owner.js";
52
+ import { createOwnerWebSocketTransport } from "./Owner.js";
53
+ import type {
47
54
  Queries,
48
55
  QueriesToQueryRowsPromises,
49
56
  Query,
50
57
  QueryRows,
51
- QueryRowsMap,
52
58
  Row,
53
- serializeQuery,
54
- SubscribedQueries,
59
+ RowsByQueryMap,
55
60
  } from "./Query.js";
56
- import {
57
- CreateQuery,
61
+ import { applyPatches } from "./Query.js";
62
+ import type {
58
63
  EvoluSchema,
59
- evoluSchemaToDbSchema,
60
64
  IndexesConfig,
61
- insertable,
62
- kysely,
63
65
  Mutation,
64
66
  MutationChange,
65
- MutationKind,
66
- MutationMapping,
67
- MutationOptions,
68
- SystemColumns,
69
- updateable,
70
- upsertable,
71
67
  ValidateSchema,
72
68
  } from "./Schema.js";
69
+ import { evoluSchemaToSqliteSchema } from "./Schema.js";
70
+ import type {
71
+ DbWorkerInput,
72
+ DbWorkerOutput,
73
+ EvoluInput,
74
+ EvoluOutput,
75
+ EvoluTabOutput,
76
+ SharedWorkerDep,
77
+ } from "./Shared.js";
73
78
  import { DbChange } from "./Storage.js";
74
- import { initialSyncState, SyncOwner, SyncState } from "./Sync.js";
75
- import { TimestampError } from "./Timestamp.js";
79
+ import type { Timestamp } from "./Timestamp.js";
80
+
81
+ export interface EvoluConfig {
82
+ /**
83
+ * The app name. Evolu is multitenant - it can run multiple instances
84
+ * concurrently. The same app can have multiple instances for different
85
+ * accounts.
86
+ *
87
+ * Evolu derives the final instance name from `appName` and `appOwner` in
88
+ * {@link EvoluConfig}. The derived instance name is used as the SQLite
89
+ * database filename and as the log prefix. This ensures that each
90
+ * {@link Owner} gets a separate local database while preserving a readable app
91
+ * prefix.
92
+ *
93
+ * ### Example
94
+ *
95
+ * ```ts
96
+ * // appName: AppName.orThrow("MyApp")
97
+ * ```
98
+ */
99
+ readonly appName: AppName;
100
+
101
+ /**
102
+ * {@link AppOwner} used to create this {@link Evolu} instance.
103
+ *
104
+ * Exposed as {@link Evolu.appOwner}. If `appOwner` is not passed, Evolu
105
+ * creates one.
106
+ *
107
+ * AppOwner controls access to the encrypted local SQLite database. If its
108
+ * secret material (Owner secret / Mnemonic) is not stored safely, data
109
+ * written by that instance is permanently inaccessible.
110
+ *
111
+ * Best onboarding UX is accountless first use: let users try a ready-to-use
112
+ * app, then prompt backup of `evolu.appOwner`.
113
+ *
114
+ * Recommended usage:
115
+ *
116
+ * - Omit `appOwner` for first run, then persist `evolu.appOwner` after user
117
+ * activity and guide the user to back it up.
118
+ * - Pass `appOwner` restored from secure storage (for example, Expo
119
+ * SecureStore, WebAuthn-backed storage, or app-managed account recovery
120
+ * flow).
121
+ */
122
+ readonly appOwner: AppOwner;
123
+
124
+ /**
125
+ * Transport configuration for sync and backup.
126
+ *
127
+ * If not specified, Evolu uses the default Evolu relay. Pass one or more
128
+ * transports to override it with your own relays. Pass an empty array to
129
+ * disable sync, which is useful when sync should be configured later.
130
+ *
131
+ * Empty transports start the instance without sync. In that case,
132
+ * {@link Evolu.useOwner} must be called with explicit non-empty transports to
133
+ * enable sync for any Owner, including the AppOwner.
134
+ *
135
+ * **Redundancy:** The ideal setup uses at least two completely independent
136
+ * relays - for example, a home relay and a geographically separate relay.
137
+ * Data is sent to both relays simultaneously, providing true redundancy
138
+ * similar to using two independent clouds. This eliminates vendor lock-in and
139
+ * ensures your app continues working regardless of circumstances - whether
140
+ * home relay hardware fails or disappears, or a remote relay provider shuts
141
+ * down.
142
+ *
143
+ * Currently supports:
144
+ *
145
+ * - WebSocket: Real-time bidirectional communication with relay servers
146
+ *
147
+ * Use {@link createOwnerWebSocketTransport} to create WebSocket transport
148
+ * configurations with proper URL formatting and {@link OwnerId} inclusion. The
149
+ * {@link OwnerId} in the URL enables relay authentication, allowing relay
150
+ * servers to control access (e.g., for paid tiers or private instances).
151
+ *
152
+ * The default value is:
153
+ *
154
+ * `{ type: "WebSocket", url: "wss://free.evoluhq.com" }`.
155
+ *
156
+ * ### Example
157
+ *
158
+ * ```ts
159
+ * // Single WebSocket relay
160
+ * transports: [{ type: "WebSocket", url: "wss://relay1.example.com" }];
161
+ *
162
+ * // Multiple WebSocket relays for redundancy
163
+ * transports: [
164
+ * { type: "WebSocket", url: "wss://relay1.example.com" },
165
+ * { type: "WebSocket", url: "wss://relay2.example.com" },
166
+ * { type: "WebSocket", url: "wss://relay3.example.com" },
167
+ * ];
168
+ *
169
+ * // Local-only instance (no sync) - useful for device settings or when relay
170
+ * // URL will be provided later (e.g., after authentication), allowing users
171
+ * // to work offline before the app connects
172
+ * transports: [];
173
+ *
174
+ * // Using createOwnerWebSocketTransport helper for relay authentication
175
+ * transports: [
176
+ * createOwnerWebSocketTransport({
177
+ * url: "ws://localhost:4000",
178
+ * ownerId,
179
+ * }),
180
+ * ];
181
+ * ```
182
+ */
183
+ readonly transports?: ReadonlyArray<OwnerTransport>;
184
+
185
+ /**
186
+ * Keep local data only in memory instead of persisting it on this device.
187
+ * Useful for testing, temporary data, or sensitive data that should not be
188
+ * recoverable from local storage after the process ends.
189
+ *
190
+ * Local data stored in memory is completely destroyed when the process ends.
191
+ * Sync can still persist data remotely when transports are enabled.
192
+ *
193
+ * The default value is: `false`.
194
+ */
195
+ readonly memoryOnly?: boolean;
76
196
 
77
- export interface EvoluConfig extends Partial<DbConfig> {
78
197
  /**
79
198
  * Use the `indexes` option to define SQLite indexes.
80
199
  *
@@ -98,79 +217,153 @@ export interface EvoluConfig extends Partial<DbConfig> {
98
217
  readonly indexes?: IndexesConfig;
99
218
 
100
219
  /**
101
- * URL to reload browser tabs after reset or restore.
220
+ * Called when this instance's local database is deleted.
102
221
  *
103
- * The default value is `/`.
222
+ * Apps can use this to update UI immediately because the corresponding
223
+ * {@link Evolu} instance becomes unusable after local database deletion.
224
+ */
225
+ readonly onDatabaseDeleted?: () => void;
226
+
227
+ /**
228
+ * Called when local data for an {@link Owner} is deleted.
104
229
  *
105
- * Note: This option will be moved to web platform deps in the next major
106
- * version.
230
+ * Apps can use this to update UI immediately because that owner stops being
231
+ * used across tabs and instances.
107
232
  */
108
- readonly reloadUrl?: string;
233
+ readonly onOwnerDeleted?: (owner: Owner) => void;
109
234
  }
110
235
 
111
- export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
236
+ /**
237
+ * Application name.
238
+ *
239
+ * Evolu uses AppName as the base prefix for {@link Evolu.name}. The final
240
+ * instance name is derived per {@link AppOwner} as
241
+ * `${appName}-${createIdFromString(appOwner.id)}`.
242
+ *
243
+ * Uses the same safe alphabet as {@link UrlSafeString} (letters, digits, `-`,
244
+ * `_`) and must be between 1 and 41 characters.
245
+ */
246
+ export const AppName = /*#__PURE__*/ brand("AppName", UrlSafeString, (value) =>
247
+ value.length >= 1 && value.length <= 41
248
+ ? ok(value)
249
+ : err<AppNameError>({ type: "AppName", value }),
250
+ );
251
+ export type AppName = typeof AppName.Type;
252
+ export interface AppNameError extends TypeError<"AppName"> {}
253
+
254
+ export const testAppName = /*#__PURE__*/ AppName.orThrow("AppName");
255
+
256
+ /**
257
+ * Local-first SQL database with typed queries, mutations, and sync.
258
+ *
259
+ * TODO: Better docs.
260
+ */
261
+ export interface Evolu<
262
+ S extends EvoluSchema = EvoluSchema,
263
+ > extends AsyncDisposable {
264
+ /**
265
+ * Evolu instance name is derived from {@link EvoluConfig.appName} and
266
+ * {@link AppOwner}'s hash.
267
+ */
268
+ readonly name: Name;
269
+
270
+ /** {@link AppOwner}. */
271
+ readonly appOwner: AppOwner;
272
+
112
273
  /**
113
- * Subscribe to {@link EvoluError} changes.
274
+ * Inserts a row and returns the generated {@link Id}.
275
+ *
276
+ * All non-nullable columns are required, nullable columns are optional, and
277
+ * `id` is omitted because Evolu generates it automatically. This ensures
278
+ * every row has a globally unique, conflict-free identifier without
279
+ * coordination.
114
280
  *
115
281
  * ### Example
116
282
  *
117
283
  * ```ts
118
- * const unsubscribe = evolu.subscribeError(() => {
119
- * const error = evolu.getError();
120
- * console.log(error);
284
+ * const { id } = evolu.insert("todo", {
285
+ * title: NonEmptyString100.orThrow("Learn Evolu"),
121
286
  * });
287
+ *
288
+ * // With onComplete callback
289
+ * evolu.insert(
290
+ * "todo",
291
+ * { title: NonEmptyString100.orThrow("Another todo") },
292
+ * { onComplete: () => console.log("Insert completed") },
293
+ * );
122
294
  * ```
295
+ *
296
+ * @see {@link Mutation}
123
297
  */
124
- readonly subscribeError: StoreSubscribe;
125
-
126
- /** Get {@link EvoluError}. */
127
- readonly getError: () => EvoluError | null;
298
+ readonly insert: Mutation<S, "insert">;
128
299
 
129
300
  /**
130
- * Create type-safe SQL {@link Query}.
301
+ * Updates a row and returns the {@link Id}.
131
302
  *
132
- * Evolu uses Kysely - the type-safe SQL query builder for TypeScript. See
133
- * https://kysely.dev.
303
+ * Only `id` is required, all other columns are optional.
134
304
  *
135
- * All this function does is compile the Kysely query and serialize it into a
136
- * unique string. Both operations are fast and cheap.
305
+ * ### Example
306
+ *
307
+ * ```ts
308
+ * const { id } = evolu.update("todo", {
309
+ * id: todoId,
310
+ * title: NonEmptyString100.orThrow("Updated title"),
311
+ * });
137
312
  *
138
- * For mutations, use {@link Evolu#insert}, {@link Evolu#update}, or
139
- * {@link Evolu#upsert}.
313
+ * // Soft delete
314
+ * evolu.update("todo", { id: todoId, isDeleted: sqliteTrue });
315
+ * ```
316
+ *
317
+ * @see {@link Mutation}
318
+ */
319
+ readonly update: Mutation<S, "update">;
320
+
321
+ /**
322
+ * Upserts a row and returns the {@link Id}.
323
+ *
324
+ * Like insert, but requires an `id`. Useful for rows with external ID via
325
+ * {@link createIdFromString}. All other non-nullable columns are required,
326
+ * nullable columns are optional.
327
+ *
328
+ * `createdAt` and `updatedAt` cannot be set manually. Evolu derives both from
329
+ * the CRDT {@link Timestamp} — they share the same value, encoded once to
330
+ * avoid redundancy. Timestamps are always generated by Evolu to preserve CRDT
331
+ * consistency guarantees.
140
332
  *
141
333
  * ### Example
142
334
  *
143
335
  * ```ts
144
- * const allTodos = evolu.createQuery((db) =>
145
- * db.selectFrom("todo").selectAll(),
146
- * );
147
- *
148
- * const todoById = (id: TodoId) =>
149
- * evolu.createQuery((db) =>
150
- * db.selectFrom("todo").selectAll().where("id", "=", id),
151
- * );
336
+ * const stableId = createIdFromString("my-todo-1");
337
+ * const { id } = evolu.upsert("todo", {
338
+ * id: stableId,
339
+ * title: NonEmptyString100.orThrow("Learn Evolu"),
340
+ * });
152
341
  * ```
342
+ *
343
+ * @see {@link Mutation}
153
344
  */
154
- readonly createQuery: CreateQuery<S>;
345
+ readonly upsert: Mutation<S, "upsert">;
155
346
 
156
347
  /**
157
348
  * Load {@link Query} and return a promise with {@link QueryRows}.
158
349
  *
159
350
  * The returned promise always resolves successfully because there is no
160
- * reason why loading should fail. All data are local, and the query is typed.
161
- * Unexpected errors are handled with {@link Evolu#subscribeError}.
351
+ * reason why loading should fail. All data are local, and the query is
352
+ * typed.
162
353
  *
163
- * Loading is batched, and returned promises are cached until resolved to
164
- * prevent redundant database queries and to support React Suspense (which
165
- * requires stable promise references while pending).
354
+ * Loading is batched. Returned promises are cached while pending and can be
355
+ * reused after fulfillment until mutation-driven invalidation, which prevents
356
+ * redundant database queries and supports React Suspense (stable references
357
+ * while pending).
166
358
  *
167
359
  * To subscribe a query for automatic updates, use
168
- * {@link Evolu#subscribeQuery}.
360
+ * {@link Evolu.subscribeQuery}.
169
361
  *
170
362
  * ### Example
171
363
  *
172
364
  * ```ts
173
- * const allTodos = evolu.createQuery((db) =>
365
+ * const createQuery = createQueryBuilder(Schema);
366
+ * const allTodos = createQuery((db) =>
174
367
  * db.selectFrom("todo").selectAll(),
175
368
  * );
176
369
  * evolu.loadQuery(allTodos).then((rows) => {
@@ -178,7 +371,9 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
178
371
  * });
179
372
  * ```
180
373
  */
181
- readonly loadQuery: <R extends Row>(query: Query<R>) => Promise<QueryRows<R>>;
374
+ readonly loadQuery: <R extends Row>(
375
+ query: Query<S, R>,
376
+ ) => Promise<QueryRows<R>>;
182
377
 
183
378
  /**
184
379
  * Load an array of {@link Query} queries and return an array of
@@ -191,7 +386,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
191
386
  * evolu.loadQueries([allTodos, todoById(1)]);
192
387
  * ```
193
388
  */
194
- readonly loadQueries: <R extends Row, Q extends Queries<R>>(
389
+ readonly loadQueries: <Q extends Queries<S>>(
195
390
  queries: [...Q],
196
391
  ) => [...QueriesToQueryRowsPromises<Q>];
197
392
 
@@ -206,7 +401,9 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
206
401
  * });
207
402
  * ```
208
403
  */
209
- readonly subscribeQuery: (query: Query) => StoreSubscribe;
404
+ readonly subscribeQuery: (
405
+ query: Query<S>,
406
+ ) => (listener: Listener) => Unsubscribe;
210
407
 
211
408
  /**
212
409
  * Get {@link QueryRows}.
@@ -219,864 +416,727 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> extends Disposable {
219
416
  * });
220
417
  * ```
221
418
  */
222
- readonly getQueryRows: <R extends Row>(query: Query<R>) => QueryRows<R>;
419
+ readonly getQueryRows: <R extends Row>(query: Query<S, R>) => QueryRows<R>;
223
420
 
224
421
  /**
225
- * Promise that resolves to {@link AppOwner} when available.
226
- *
227
- * Note: With web-only deps, this promise will not resolve during SSR because
228
- * there is no AppOwner on the server.
422
+ * Exports the SQLite database file.
229
423
  *
230
- * ### Example
424
+ * Exports are sequential: concurrent calls share one pending export instead
425
+ * of starting parallel exports.
231
426
  *
232
- * ```ts
233
- * const owner = await evolu.appOwner;
234
- * ```
427
+ * The pending promise rejects if this {@link Evolu} instance is disposed
428
+ * before export completion.
235
429
  */
236
- readonly appOwner: Promise<AppOwner>;
430
+ readonly exportDatabase: () => Promise<Uint8Array<ArrayBuffer>>;
431
+
432
+ // TODO: Add exportHistory.
237
433
 
238
434
  /**
239
- * Inserts a row into the database and returns a {@link Result} with the new
240
- * {@link Id}.
241
- *
242
- * The first argument is the table name, and the second is an object
243
- * containing the row data. An optional third argument provides mutation
244
- * options including an `onComplete` callback and `onlyValidate` flag.
435
+ * Delete the local SQLite database for this {@link Evolu} instance on the
436
+ * current device.
245
437
  *
246
- * Returns a Result type - use `.ok` to check if the insertion succeeded, and
247
- * `.value.id` to access the generated ID on success, or `.error` to handle
248
- * validation errors.
249
- *
250
- * Evolu does not use SQL for mutations to ensure data can be safely and
251
- * predictably merged without conflicts. Explicit mutations also allow Evolu
252
- * to automatically update {@link SystemColumns}.
253
- *
254
- * ### Example
255
- *
256
- * ```ts
257
- * const result = evolu.insert("todo", {
258
- * title: "Learn Evolu",
259
- * isCompleted: false,
260
- * });
438
+ * **Warning**: This first drops all tables. “The dropped table is completely
439
+ * removed from the database schema and the disk file. The table can not be
440
+ * recovered. All indices and triggers associated with the table are also
441
+ * deleted.” https://sqlite.org/lang_droptable.html
261
442
  *
262
- * if (result.ok) {
263
- * console.log("Todo created with ID:", result.value.id);
264
- * } else {
265
- * console.error("Validation error:", result.error);
266
- * }
443
+ * After that, it deletes the SQLite file identified by {@link Evolu.name},
444
+ * permanently forgetting all local data for that instance on this device.
267
445
  *
268
- * // With onComplete callback
269
- * evolu.insert(
270
- * "todo",
271
- * { title: "Another todo" },
272
- * {
273
- * onComplete: () => {
274
- * console.log("Insert completed");
275
- * },
276
- * },
277
- * );
278
- * ```
446
+ * All instances identified by {@link Evolu.name} will be self-disposed. Use
447
+ * {@link EvoluConfig.onDatabaseDeleted} to update app UI when that happens.
279
448
  */
280
- insert: Mutation<S, "insert">;
449
+ readonly deleteDatabase: () => void;
281
450
 
282
451
  /**
283
- * Updates a row in the database and returns a {@link Result} with the existing
284
- * {@link Id}.
285
- *
286
- * The first argument is the table name, and the second is an object
287
- * containing the row data including the required `id` field. An optional
288
- * third argument provides mutation options including an `onComplete` callback
289
- * and `onlyValidate` flag.
452
+ * Delete all local data for a specific {@link Owner}.
290
453
  *
291
- * Returns a Result type - use `.ok` to check if the update succeeded, and
292
- * `.value.id` to access the ID on success, or `.error` to handle validation
293
- * errors.
454
+ * This deletes all rows whose `ownerId` matches the provided owner and
455
+ * deletes that owner's local history as well.
294
456
  *
295
- * Evolu does not use SQL for mutations to ensure data can be safely and
296
- * predictably merged without conflicts. Explicit mutations also allow Evolu
297
- * to automatically update {@link SystemColumns}.
298
- *
299
- * ### Example
300
- *
301
- * ```ts
302
- * const result = evolu.update("todo", {
303
- * id: todoId,
304
- * title: "Updated title",
305
- * isCompleted: true,
306
- * });
307
- *
308
- * if (result.ok) {
309
- * console.log("Todo updated with ID:", result.value.id);
310
- * } else {
311
- * console.error("Validation error:", result.error);
312
- * }
313
- *
314
- * // To delete a row, set isDeleted to true
315
- * evolu.update("todo", { id: todoId, isDeleted: true });
316
- *
317
- * // With onComplete callback
318
- * evolu.update(
319
- * "todo",
320
- * { id: todoId, title: "New title" },
321
- * {
322
- * onComplete: () => {
323
- * console.log("Update completed");
324
- * },
325
- * },
326
- * );
327
- * ```
457
+ * It also stops using that owner across all tabs and instances. Use
458
+ * {@link EvoluConfig.onOwnerDeleted} to update app UI when that happens.
328
459
  */
329
- update: Mutation<S, "update">;
460
+ readonly deleteOwner: (owner: Owner) => void;
330
461
 
331
462
  /**
332
- * Upserts a row in the database and returns a {@link Result} with the existing
333
- * {@link Id}.
334
- *
335
- * The first argument is the table name, and the second is an object
336
- * containing the row data including the required `id` field. An optional
337
- * third argument provides mutation options including an `onComplete` callback
338
- * and `onlyValidate` flag.
463
+ * Use an Owner for sync. Returns a {@link UnuseOwner}.
339
464
  *
340
- * This function allows you to use custom IDs and optionally set `createdAt`,
341
- * which is useful for external systems, data migrations, or when the same row
342
- * may already be created on a different device.
465
+ * Using an Owner means syncing it with the provided transports, or with the
466
+ * default transports defined in {@link EvoluConfig} when transports are
467
+ * omitted.
343
468
  *
344
- * Returns a Result type - use `.ok` to check if the upsert succeeded, and
345
- * `.value.id` to access the ID on success, or `.error` to handle validation
346
- * errors.
469
+ * If {@link EvoluConfig.transports} is an empty array, this method must be
470
+ * called with explicit non-empty transports.
347
471
  *
348
- * Evolu does not use SQL for mutations to ensure data can be safely and
349
- * predictably merged without conflicts. Explicit mutations also allow Evolu
350
- * to automatically update {@link SystemColumns}.
472
+ * Transports are automatically deduplicated and reference-counted, so
473
+ * multiple Owners using the same transport will share a single connection.
351
474
  *
352
475
  * ### Example
353
476
  *
354
477
  * ```ts
355
- * // Use deterministic ID for stable upserts across devices
356
- * const stableId = createIdFromString("my-todo-1");
357
- *
358
- * const result = evolu.upsert("todo", {
359
- * id: stableId,
360
- * title: "Learn Evolu",
361
- * isCompleted: false,
362
- * });
478
+ * // Use an Owner (starts syncing).
479
+ * const unuseOwner = evolu.useOwner(shardOwner, [
480
+ * createOwnerWebSocketTransport({
481
+ * url: "ws://localhost:4000",
482
+ * ownerId: shardOwner.id,
483
+ * }),
484
+ * ]);
363
485
  *
364
- * if (result.ok) {
365
- * console.log("Todo upserted with ID:", result.value.id);
366
- * } else {
367
- * console.error("Validation error:", result.error);
368
- * }
369
- *
370
- * // Data migration with custom createdAt
371
- * evolu.upsert("todo", {
372
- * id: externalId,
373
- * title: "Migrated todo",
374
- * createdAt: new Date("2023-01-01"), // Preserve original timestamp
375
- * });
486
+ * // Later, stop using the Owner.
487
+ * unuseOwner();
376
488
  *
377
- * // With onComplete callback
378
- * evolu.upsert(
379
- * "todo",
380
- * { id: stableId, title: "Updated title" },
381
- * {
382
- * onComplete: () => {
383
- * console.log("Upsert completed");
384
- * },
385
- * },
386
- * );
489
+ * // Bulk operations.
490
+ * const unuseOwners = owners.map(evolu.useOwner);
491
+ * // Later: for (const unuse of unuseOwners) unuse();
387
492
  * ```
388
493
  */
389
- upsert: Mutation<S, "upsert">;
390
-
391
- /**
392
- * Delete {@link AppOwner} and all their data from the current device. After
393
- * the deletion, Evolu will purge the application state. For browsers, this
394
- * will reload all tabs using Evolu. For native apps, it will restart the
395
- * app.
396
- *
397
- * Reloading can be turned off via options if you want to provide a different
398
- * UX.
399
- */
400
- readonly resetAppOwner: (options?: {
401
- readonly reload?: boolean;
402
- }) => Promise<void>;
403
-
404
- /**
405
- * Restore {@link AppOwner} with all their synced data. It uses
406
- * {@link Evolu#resetAppOwner}, so be careful.
407
- */
408
- readonly restoreAppOwner: (
409
- mnemonic: Mnemonic,
410
- options?: {
411
- readonly reload?: boolean;
412
- },
413
- ) => Promise<void>;
414
-
415
- /**
416
- * Reload the app in a platform-specific way. For browsers, this will reload
417
- * all tabs using Evolu. For native apps, it will restart the app.
418
- */
419
- readonly reloadApp: () => void;
494
+ readonly useOwner: (
495
+ owner: ReadonlyOwner | Owner,
496
+ transports?: NonEmptyReadonlyArray<OwnerTransport>,
497
+ ) => UnuseOwner;
498
+ }
420
499
 
421
- /**
422
- * Export SQLite database file as Uint8Array.
423
- *
424
- * In the future, it will be possible to import a database and export/import
425
- * history for 1:1 migrations across owners.
426
- */
427
- readonly exportDatabase: () => Promise<Uint8Array<ArrayBuffer>>;
500
+ /** Function returned by {@link Evolu.useOwner} to stop using an Owner for sync. */
501
+ export type UnuseOwner = () => void;
428
502
 
503
+ export interface EvoluErrorDep {
429
504
  /**
430
- * Use a {@link SyncOwner}. Returns a {@link UnuseOwner}.
431
- *
432
- * Using an owner means syncing it with its transports, or the transports
433
- * defined in Evolu config if the owner has no transports defined.
505
+ * {@link ReadonlyStore} of {@link EvoluError} shared by all {@link Evolu}
506
+ * instances created from the same {@link createEvoluDeps} result.
434
507
  *
435
- * Transport are automatically deduplicated and reference-counted, so multiple
436
- * owners using the same transport will share a single connection.
508
+ * Subscribe once to show user-facing messages across all instances. Logging
509
+ * is handled by platform {@link createRun} global error handlers.
437
510
  *
438
511
  * ### Example
439
512
  *
440
513
  * ```ts
441
- * // Use an owner (starts syncing).
442
- * const unuseOwner = evolu.useOwner(shardOwner);
443
- *
444
- * // Later, stop using the owner.
445
- * unuseOwner();
514
+ * deps.evoluError.subscribe(() => {
515
+ * const error = deps.evoluError.get();
516
+ * if (!error) return;
446
517
  *
447
- * // Bulk operations.
448
- * const unuseOwners = owners.map((owner) => evolu.useOwner(owner));
449
- * // Later: for (const unuse of unuseOwners) unuse();
518
+ * switch (error.type) {
519
+ * case "InvalidComputerClock":
520
+ * // Show guidance specific to the detected error.
521
+ * showMessage(
522
+ * "Your system clock appears incorrect. Please fix it.",
523
+ * );
524
+ * break;
525
+ * default:
526
+ * // Show a generic user message for other operational errors.
527
+ * showMessage("Something went wrong. Please try again.");
528
+ * }
529
+ * });
450
530
  * ```
451
- *
452
- * @experimental
453
531
  */
454
- readonly useOwner: (owner: SyncOwner) => UnuseOwner;
532
+ readonly evoluError: ReadonlyStore<EvoluError | null>;
455
533
  }
456
534
 
457
- /** Function returned by {@link Evolu#useOwner} to stop using an {@link SyncOwner}. */
458
- export type UnuseOwner = () => void;
459
-
460
- /** Represents errors that can occur in Evolu. */
461
- export type EvoluError =
462
- | ProtocolError
463
- | SqliteError
464
- | SymmetricCryptoDecryptError
465
- | TimestampError
466
- | TransferableError;
467
-
468
- interface InternalEvoluInstance<
469
- S extends EvoluSchema = EvoluSchema,
470
- > extends Evolu<S> {
471
- /**
472
- * Ensure tables and columns defined in {@link EvoluSchema} exist in the
473
- * database. This function is for hot reloading.
474
- */
475
- readonly ensureSchema: (schema: EvoluSchema) => void;
476
- }
477
-
478
- export type EvoluDeps = ConsoleDep &
479
- CreateDbWorkerDep &
480
- Partial<FlushSyncDep> &
481
- RandomBytesDep &
482
- ReloadAppDep &
483
- TimeDep;
484
-
485
- const evoluInstances = createInstances<SimpleName, InternalEvoluInstance>();
486
-
487
535
  /**
488
- * Unique identifier for the current browser tab or app instance, lazily
489
- * initialized on first use to distinguish between multiple tabs.
536
+ * Shared platform dependencies for creating {@link Evolu} instances.
537
+ *
538
+ * Includes platform adapters, the shared {@link EvoluErrorDep.evoluError} store,
539
+ * and disposal for owned resources.
490
540
  */
491
- let tabId: Id | null = null;
541
+ export type EvoluDeps = EvoluPlatformDeps & EvoluErrorDep & Disposable;
492
542
 
493
543
  /**
494
- * Creates an {@link Evolu} instance for a platform configured with the specified
495
- * {@link EvoluSchema} and optional {@link EvoluConfig} providing a typed
496
- * interface for querying, mutating, and syncing your application's data.
497
- *
498
- * ### Example
499
- *
500
- * ```ts
501
- * const TodoId = id("Todo");
502
- * type TodoId = InferType<typeof TodoId>;
503
- *
504
- * const TodoCategoryId = id("TodoCategory");
505
- * type TodoCategoryId = InferType<typeof TodoCategoryId>;
506
- *
507
- * const NonEmptyString50 = maxLength(50, NonEmptyString);
508
- * type NonEmptyString50 = InferType<typeof NonEmptyString50>;
544
+ * Platform-specific dependencies required to create {@link EvoluDeps}.
509
545
  *
510
- * const Schema = {
511
- * todo: {
512
- * id: TodoId,
513
- * title: NonEmptyString1000,
514
- * isCompleted: nullOr(SqliteBoolean),
515
- * categoryId: nullOr(TodoCategoryId),
516
- * },
517
- * todoCategory: {
518
- * id: TodoCategoryId,
519
- * name: NonEmptyString50,
520
- * },
521
- * };
522
- *
523
- * const evolu = createEvolu(evoluReactDeps)(Schema);
524
- * ```
546
+ * Provides worker and channel adapters plus optional platform integrations for
547
+ * logging and synchronous UI flush.
548
+ */
549
+ export type EvoluPlatformDeps = CreateDbWorkerDep &
550
+ CreateMessageChannelDep &
551
+ ReloadAppDep &
552
+ SharedWorkerDep &
553
+ Partial<ConsoleDep> &
554
+ Partial<FlushSyncDep>;
555
+
556
+ /**
557
+ * Creates shared dependencies used by all {@link createEvolu} instances on a
558
+ * platform.
525
559
  *
526
- * ### Instance Caching
560
+ * Call this once per platform and reuse the returned deps when creating
561
+ * multiple Evolu instances. The returned deps object owns long-lived resources
562
+ * such as worker channels and the shared {@link EvoluErrorDep.evoluError}
563
+ * store.
527
564
  *
528
- * `createEvolu` caches instances using {@link Instances} by {@link EvoluConfig}
529
- * name to enable hot reloading and prevent database corruption from multiple
530
- * connections. For testing, use unique instance names to ensure proper
531
- * isolation.
565
+ * Dispose it only during app shutdown.
532
566
  */
533
- export const createEvolu =
534
- (deps: EvoluDeps) =>
535
- <S extends EvoluSchema>(
536
- schema: ValidateSchema<S> extends never ? S : ValidateSchema<S>,
537
- config?: EvoluConfig,
538
- ): Evolu<S> =>
539
- evoluInstances.ensure(
540
- config?.name ?? defaultDbConfig.name,
541
- () => createEvoluInstance(deps)(schema as EvoluSchema, config),
542
- (evolu) => {
543
- // Hot reloading. Note that indexes are intentionally omitted.
544
- evolu.ensureSchema(schema as EvoluSchema);
545
- },
546
- ) as Evolu<S>;
547
-
548
- const createEvoluInstance =
549
- (deps: EvoluDeps) =>
550
- (schema: EvoluSchema, config?: EvoluConfig): InternalEvoluInstance => {
551
- deps.console.enabled = config?.enableLogging ?? false;
552
-
553
- const { indexes, reloadUrl = "/", ...partialDbConfig } = config ?? {};
554
-
555
- const dbConfig: DbConfig = { ...defaultDbConfig, ...partialDbConfig };
556
-
557
- deps.console.log("[evolu]", "createEvoluInstance", {
558
- name: dbConfig.name,
559
- });
560
-
561
- const errorStore = createStore<EvoluError | null>(null);
562
- const rowsStore = createStore<QueryRowsMap>(new Map());
567
+ export const createEvoluDeps = (deps: EvoluPlatformDeps): EvoluDeps => {
568
+ const { createMessageChannel, sharedWorker } = deps;
569
+ const console = deps.console ?? createConsole();
570
+
571
+ const stack = new DisposableStack();
572
+ stack.use(sharedWorker);
573
+ const evoluError = stack.use(createStore<EvoluError | null>(null));
574
+
575
+ const tabChannel = stack.use(createMessageChannel<EvoluTabOutput>());
576
+ tabChannel.port2.onMessage = (message) => {
577
+ switch (message.type) {
578
+ case "OnConsoleEntry":
579
+ console.write(message.entry);
580
+ // Fallback channel for unexpected errors without EvoluError typing.
581
+ if (message.entry.method === "error") {
582
+ evoluError.set(createUnknownError(message.entry.args));
583
+ }
584
+ break;
563
585
 
564
- const { promise: appOwner, resolve: resolveAppOwner } =
565
- Promise.withResolvers<AppOwner>();
586
+ case "OnError":
587
+ evoluError.set(message.error);
588
+ // Keep typed errors visible in logs as operational failures.
589
+ console.error(message.error);
590
+ break;
566
591
 
567
- if (config?.externalAppOwner) {
568
- resolveAppOwner(config.externalAppOwner);
592
+ default:
593
+ exhaustiveCheck(message);
569
594
  }
595
+ };
570
596
 
571
- // TODO: Update it for the owner-api
572
- const _syncStore = createStore<SyncState>(initialSyncState);
597
+ sharedWorker.port.postMessage(
598
+ {
599
+ type: "InitTab",
600
+ consoleLevel: console.getLevel(),
601
+ port: tabChannel.port1.native,
602
+ },
603
+ [tabChannel.port1.native],
604
+ );
573
605
 
574
- const subscribedQueries = createSubscribedQueries(rowsStore);
575
- const loadingPromises = createLoadingPromises(subscribedQueries);
576
- const onCompleteCallbacks = createCallbacks(deps);
577
- const exportCallbacks = createCallbacks<Uint8Array<ArrayBuffer>>(deps);
606
+ const moved = stack.move();
578
607
 
579
- const dbWorker = deps.createDbWorker(dbConfig.name);
608
+ return {
609
+ ...deps,
610
+ evoluError,
611
+ [Symbol.dispose]: () => moved.dispose(),
612
+ };
613
+ };
580
614
 
581
- const getTabId = () => {
582
- tabId ??= createId(deps);
583
- return tabId;
584
- };
615
+ /**
616
+ * Creates an {@link Evolu} instance from {@link EvoluSchema} and
617
+ * {@link EvoluConfig}.
618
+ */
619
+ export const createEvolu =
620
+ <S extends EvoluSchema>(
621
+ schema: ValidateSchema<S> extends never ? S : ValidateSchema<S>,
622
+ config: EvoluConfig,
623
+ ): Task<Evolu<S>, never, EvoluPlatformDeps> =>
624
+ async (run) => {
625
+ const {
626
+ appName,
627
+ appOwner,
628
+ memoryOnly = false,
629
+ transports = [{ type: "WebSocket", url: "wss://free.evoluhq.com" }],
630
+ } = config;
585
631
 
586
- // Worker responses are delivered to all tabs. Each case must handle this
587
- // properly (e.g., AppOwner promise resolves only once, tabId filtering).
588
- dbWorker.onMessage((message) => {
589
- switch (message.type) {
590
- case "onError": {
591
- errorStore.set(message.error);
592
- break;
593
- }
632
+ const name = Name.orThrow(`${appName}-${createIdFromString(appOwner.id)}`);
633
+ const console = run.deps.console.child(name).child("Evolu");
634
+ console.info("createEvolu");
594
635
 
595
- case "onGetAppOwner": {
596
- resolveAppOwner(message.appOwner);
597
- break;
598
- }
636
+ await using stack = new AsyncDisposableStack();
599
637
 
600
- case "onQueryPatches": {
601
- if (message.tabId !== getTabId()) return;
638
+ const rowsByQueryMapStore = stack.use(
639
+ createStore<RowsByQueryMap>(new Map()),
640
+ );
641
+ const subscribedQueriesRefCount = stack.use(createRefCountByKey<Query>());
642
+
643
+ interface LoadingPromise {
644
+ /**
645
+ * React tracks `status`/`value`/`reason` on thenables passed to `use`.
646
+ * Evolu mirrors that shape so cached promises can be unwrapped
647
+ * synchronously and to keep promise-cache behavior stable.
648
+ *
649
+ * React source:
650
+ * https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberThenable.js
651
+ */
652
+ promise: Promise<QueryRows> & {
653
+ status?: "pending" | "fulfilled" | "rejected";
654
+ value?: QueryRows;
655
+ reason?: unknown;
656
+ };
657
+ resolve: (rows: QueryRows) => void;
658
+ releaseOnResolve: boolean;
659
+ }
602
660
 
603
- const state = rowsStore.get();
604
- const nextState = new Map([
605
- ...state,
606
- ...message.queryPatches.map(
607
- ({ query, patches }): [Query, ReadonlyArray<Row>] => [
608
- query,
609
- applyPatches(patches, state.get(query) ?? emptyRows),
610
- ],
611
- ),
612
- ]);
661
+ /**
662
+ * Settle pending query loads during disposal so awaiting callers and React
663
+ * `use` thenables do not hang forever during teardown.
664
+ */
665
+ const loadingPromisesByQuery = stack.adopt(
666
+ new Map<Query, LoadingPromise>(),
667
+ (loadingPromisesByQuery) => {
668
+ for (const loadingPromise of loadingPromisesByQuery.values()) {
669
+ if (loadingPromise.promise.status === "fulfilled") continue;
670
+ fulfillLoadingPromise(loadingPromise, emptyArray);
671
+ }
672
+ loadingPromisesByQuery.clear();
673
+ },
674
+ );
613
675
 
614
- for (const { query } of message.queryPatches) {
615
- loadingPromises.resolve(query, nextState.get(query) ?? emptyRows);
616
- }
676
+ const fulfillLoadingPromise = (
677
+ loadingPromise: LoadingPromise,
678
+ rows: QueryRows,
679
+ ): void => {
680
+ /**
681
+ * Pending promises must be resolved in place to preserve identity for
682
+ * current awaiters. Fulfilled promises are replaced with a new resolved
683
+ * promise so future loads see the latest rows.
684
+ */
685
+ if (loadingPromise.promise.status !== "fulfilled") {
686
+ loadingPromise.resolve(rows);
687
+ } else {
688
+ loadingPromise.promise = Promise.resolve(rows);
689
+ }
617
690
 
618
- if (deps.flushSync && message.onCompleteIds.length > 0) {
619
- deps.flushSync(() => {
620
- rowsStore.set(nextState);
621
- });
622
- } else {
623
- rowsStore.set(nextState);
624
- }
691
+ /** See {@link LoadingPromise.promise}. */
692
+ void Object.assign(loadingPromise.promise, {
693
+ status: "fulfilled",
694
+ value: rows,
695
+ });
696
+ };
625
697
 
626
- for (const id of message.onCompleteIds) {
627
- onCompleteCallbacks.execute(id);
628
- }
629
- break;
698
+ /**
699
+ * Mutations and refreshes invalidate query snapshots. Keep loading promises
700
+ * only for actively subscribed queries and release unsubscribed ones.
701
+ *
702
+ * Fulfilled promises can be dropped immediately because no awaiter is
703
+ * waiting on them. Pending promises must stay alive until they resolve so
704
+ * current awaiters keep the same promise identity.
705
+ */
706
+ const releaseUnsubscribedLoadingPromises = (): void => {
707
+ for (const [query, loadingPromise] of loadingPromisesByQuery) {
708
+ if (subscribedQueriesRefCount.has(query)) continue;
709
+
710
+ if (loadingPromise.promise.status === "fulfilled") {
711
+ loadingPromisesByQuery.delete(query);
712
+ } else {
713
+ loadingPromise.releaseOnResolve = true;
630
714
  }
715
+ }
716
+ };
631
717
 
632
- case "refreshQueries": {
633
- if (message.tabId && message.tabId === getTabId()) return;
718
+ const onMutateCompleteCallbacks = stack.use(createCallbacks(run.deps));
634
719
 
635
- const loadingPromisesQueries = loadingPromises.getQueries();
636
- loadingPromises.releaseUnsubscribedOnMutation();
720
+ let exportDatabasePending = null as PromiseWithResolvers<
721
+ Uint8Array<ArrayBuffer>
722
+ > | null;
637
723
 
638
- const queries = dedupeArray([
639
- ...loadingPromisesQueries,
640
- ...subscribedQueries.get(),
641
- ]);
724
+ stack.defer(() => {
725
+ exportDatabasePending?.reject({ type: "EvoluDisposedError" });
726
+ exportDatabasePending = null;
727
+ });
642
728
 
643
- if (isNonEmptyReadonlyArray(queries)) {
644
- dbWorker.postMessage({ type: "query", tabId: getTabId(), queries });
645
- }
729
+ const mutateBatch = stack.use(
730
+ createMicrotaskBatch<{
731
+ readonly change: MutationChange;
732
+ readonly onComplete: (() => void) | undefined;
733
+ }>((items) => {
734
+ console.debug("mutateBatch", { changeCount: items.length });
735
+ releaseUnsubscribedLoadingPromises();
736
+
737
+ postMessage({
738
+ type: "Mutate",
739
+ changes: mapArray(items, (item) => item.change),
740
+ onCompleteIds: items.flatMap((item) =>
741
+ item.onComplete
742
+ ? [onMutateCompleteCallbacks.register(item.onComplete)]
743
+ : [],
744
+ ),
745
+ subscribedQueries: subscribedQueriesRefCount.keys(),
746
+ });
747
+ }),
748
+ );
646
749
 
647
- break;
648
- }
750
+ const queryBatch = stack.use(
751
+ createMicrotaskBatch<Query>((queries) => {
752
+ const dedupedQueries = new Set(queries);
753
+ assert(
754
+ isNonEmptySet(dedupedQueries),
755
+ "Expected non-empty query batch.",
756
+ );
757
+ console.debug("queryBatch", { queryCount: dedupedQueries.size });
758
+ postMessage({ type: "Query", queries: dedupedQueries });
759
+ }),
760
+ );
649
761
 
650
- case "onReset": {
651
- if (message.reload) {
652
- deps.reloadApp(reloadUrl);
653
- } else {
654
- onCompleteCallbacks.execute(message.onCompleteId);
655
- }
656
- break;
657
- }
762
+ const useOwnerBatch = stack.use(
763
+ createMicrotaskBatch<
764
+ ExtractType<EvoluInput, "UseOwner">["actions"][number]
765
+ >((actions) => postMessage({ type: "UseOwner", actions })),
766
+ );
658
767
 
659
- case "onExport": {
660
- exportCallbacks.execute(
661
- message.onCompleteId,
662
- message.file as Uint8Array<ArrayBuffer>,
663
- );
664
- break;
665
- }
768
+ let postMessage: (input: EvoluInput) => void;
769
+
770
+ // Scope worker/channel wiring and keep only postMessage outside.
771
+ {
772
+ const { createDbWorker, createMessageChannel, sharedWorker } = run.deps;
773
+ const dbWorkerChannel = stack.use(
774
+ createMessageChannel<DbWorkerOutput, DbWorkerInput>(),
775
+ );
776
+ const evoluChannel = stack.use(
777
+ createMessageChannel<EvoluInput, EvoluOutput>(),
778
+ );
779
+
780
+ evoluChannel.port1.onMessage = (message) => {
781
+ switch (message.type) {
782
+ case "OnPatchesByQuery": {
783
+ console.debug("onPatchesByQuery", {
784
+ queryCount: message.patchesByQuery.size,
785
+ onCompleteCount: message.onCompleteIds.length,
786
+ });
787
+ const state = rowsByQueryMapStore.get();
788
+ const nextRowsByQueryMap = new Map(state);
666
789
 
667
- default:
668
- exhaustiveCheck(message);
669
- }
670
- });
790
+ for (const [query, patches] of message.patchesByQuery) {
791
+ nextRowsByQueryMap.set(
792
+ query,
793
+ applyPatches(patches, state.get(query) ?? emptyArray),
794
+ );
795
+ }
671
796
 
672
- const dbSchema = evoluSchemaToDbSchema(schema, indexes);
797
+ for (const query of message.patchesByQuery.keys()) {
798
+ const loadingPromise = loadingPromisesByQuery.get(query);
799
+ if (!loadingPromise) continue;
673
800
 
674
- const mutationTypesCache = new Map<
675
- MutationKind,
676
- Map<string, ValidMutationSize<any>>
677
- >();
801
+ const rows = nextRowsByQueryMap.get(query);
802
+ assert(rows, "Expected patched query rows to exist.");
678
803
 
679
- // Lazy create mutation Types like this: `insertable(Schema.todo)`
680
- const getMutationType = (table: string, kind: MutationKind) => {
681
- let types = mutationTypesCache.get(kind);
682
- if (!types) {
683
- types = new Map();
684
- mutationTypesCache.set(kind, types);
685
- }
686
- let type = types.get(table);
687
- if (!type) {
688
- type = { insert: insertable, update: updateable, upsert: upsertable }[
689
- kind
690
- ](schema[table]);
691
- types.set(table, type);
692
- }
693
- return type;
694
- };
804
+ fulfillLoadingPromise(loadingPromise, rows);
695
805
 
696
- dbWorker.postMessage({ type: "init", config: dbConfig, dbSchema });
806
+ /**
807
+ * Release promises flagged during mutation when they finish
808
+ * resolving. This keeps in-flight promise identity stable and
809
+ * prevents stale cache entries after completion.
810
+ */
811
+ if (loadingPromise.releaseOnResolve) {
812
+ loadingPromisesByQuery.delete(query);
813
+ }
814
+ }
697
815
 
698
- // We can't use `init` to get AppOwner because `init` runs only once per n tabs.
699
- dbWorker.postMessage({ type: "getAppOwner" });
816
+ if (run.deps.flushSync && message.onCompleteIds.length > 0) {
817
+ run.deps.flushSync(() => {
818
+ rowsByQueryMapStore.set(nextRowsByQueryMap);
819
+ });
820
+ } else {
821
+ rowsByQueryMapStore.set(nextRowsByQueryMap);
822
+ }
700
823
 
701
- const loadQueryMicrotaskQueue: Array<Query> = [];
824
+ for (const onCompleteId of message.onCompleteIds) {
825
+ onMutateCompleteCallbacks.execute(onCompleteId);
826
+ }
827
+ break;
828
+ }
702
829
 
703
- const mutateMicrotaskQueue: Array<
704
- [MutationChange | null, MutationOptions["onComplete"] | undefined]
705
- > = [];
830
+ case "RefreshQueries": {
831
+ releaseUnsubscribedLoadingPromises();
706
832
 
707
- const useOwnerMicrotaskQueue: Array<[SyncOwner, boolean, Uint8Array]> = [];
833
+ const queries = new Set<Query>([
834
+ ...loadingPromisesByQuery.keys(),
835
+ ...subscribedQueriesRefCount.keys(),
836
+ ]);
708
837
 
709
- const createMutation =
710
- <Kind extends MutationKind>(kind: Kind) =>
711
- <TableName extends keyof typeof schema>(
712
- table: TableName,
713
- props: InferInput<
714
- ObjectType<MutationMapping<(typeof schema)[TableName], Kind>>
715
- >,
716
- options?: MutationOptions,
717
- ): Result<
718
- { readonly id: InferType<(typeof schema)[TableName]["id"]> },
719
- | ValidMutationSizeError
720
- | InferErrors<
721
- ObjectType<MutationMapping<(typeof schema)[TableName], Kind>>
722
- >
723
- > => {
724
- const result = getMutationType(table, kind).fromUnknown(props);
725
-
726
- const id =
727
- kind === "insert"
728
- ? createId(deps)
729
- : (props as unknown as { id: Id }).id;
730
-
731
- if (options?.onlyValidate !== true) {
732
- if (!result.ok) {
733
- // Mark the transaction as invalid by pushing null
734
- mutateMicrotaskQueue.push([null, undefined]);
735
- } else {
736
- const { id: _, isDeleted, ...values } = result.value;
737
-
738
- const dbChange = {
739
- table,
740
- id,
741
- values,
742
- isInsert: kind === "insert" || kind === "upsert",
743
- isDelete: SqliteBoolean.is(isDeleted)
744
- ? sqliteBooleanToBoolean(isDeleted)
745
- : null,
746
- };
838
+ if (isNonEmptySet(queries)) postMessage({ type: "Query", queries });
839
+ break;
840
+ }
747
841
 
842
+ case "OnExport": {
748
843
  assert(
749
- DbChange.is(dbChange),
750
- `Invalid DbChange for table '${table}': Please check schema type errors.`,
844
+ exportDatabasePending,
845
+ "OnExport received without pending export.",
751
846
  );
752
-
753
- mutateMicrotaskQueue.push([
754
- { ...dbChange, ownerId: options?.ownerId },
755
- options?.onComplete,
756
- ]);
847
+ exportDatabasePending.resolve(message.file);
848
+ exportDatabasePending = null;
849
+ break;
757
850
  }
758
851
 
759
- if (mutateMicrotaskQueue.length === 1) {
760
- queueMicrotask(processMutationQueue);
761
- }
852
+ default:
853
+ exhaustiveCheck(message);
762
854
  }
855
+ };
856
+
857
+ sharedWorker.port.postMessage(
858
+ {
859
+ type: "CreateEvolu",
860
+ name,
861
+ evoluPort: evoluChannel.port2.native,
862
+ dbWorkerPort: dbWorkerChannel.port2.native,
863
+ },
864
+ [evoluChannel.port2.native, dbWorkerChannel.port2.native],
865
+ );
866
+
867
+ /**
868
+ * No stack.use because Evolu instances don't dispose DbWorker because
869
+ * it's SharedWorder responsibility. DbWorker can be used by another tab.
870
+ * That's required because SQLite WASM needs a single web worker.
871
+ */
872
+ createDbWorker().postMessage(
873
+ {
874
+ type: "Init",
875
+ name,
876
+ consoleLevel: console.getLevel(),
877
+ sqliteSchema: evoluSchemaToSqliteSchema(schema, config.indexes),
878
+ encryptionKey: appOwner.encryptionKey,
879
+ memoryOnly,
880
+ port: dbWorkerChannel.port1.native,
881
+ },
882
+ [dbWorkerChannel.port1.native],
883
+ );
884
+
885
+ postMessage = evoluChannel.port1.postMessage;
886
+
887
+ stack.defer(() => {
888
+ postMessage({ type: "Dispose" });
889
+ });
890
+ }
763
891
 
764
- if (result.ok) return ok({ id });
892
+ const createMutation =
893
+ <Kind extends "insert" | "update" | "upsert">(
894
+ kind: Kind,
895
+ ): Mutation<S, Kind> =>
896
+ (table, values, options) => {
897
+ assertNotDisposed(moved);
898
+
899
+ const {
900
+ id = createId(run.deps),
901
+ isDeleted,
902
+ ...changeValues
903
+ } = values as { id?: Id; isDeleted?: unknown; [key: string]: unknown };
904
+
905
+ const dbChange = {
906
+ table,
907
+ id,
908
+ values: changeValues,
909
+ isInsert: kind === "insert" || kind === "upsert",
910
+ isDelete: SqliteBoolean.is(isDeleted)
911
+ ? sqliteBooleanToBoolean(isDeleted)
912
+ : null,
913
+ };
765
914
 
766
- return err(
767
- result.error as
768
- | ValidMutationSizeError
769
- | InferErrors<
770
- ObjectType<MutationMapping<(typeof schema)[TableName], Kind>>
771
- >,
915
+ assert(
916
+ DbChange.is(dbChange),
917
+ `Invalid DbChange for table '${String(table)}'.`,
772
918
  );
919
+
920
+ mutateBatch.push({
921
+ change: { ...dbChange, ownerId: options?.ownerId ?? appOwner.id },
922
+ onComplete: options?.onComplete,
923
+ });
924
+
925
+ return { id };
773
926
  };
774
927
 
775
- const processMutationQueue = () => {
776
- const changes: Array<MutationChange> = [];
777
- const onCompletes = [];
928
+ const loadQuery = <R extends Row>(
929
+ query: Query<S, R>,
930
+ ): Promise<QueryRows<R>> => {
931
+ assertNotDisposed(moved);
778
932
 
779
- for (const [change, onComplete] of mutateMicrotaskQueue) {
780
- if (change !== null) changes.push(change);
781
- if (onComplete) onCompletes.push(onComplete);
933
+ const loadingPromise = loadingPromisesByQuery.get(query);
934
+ if (loadingPromise) {
935
+ return loadingPromise.promise as Promise<QueryRows<R>>;
782
936
  }
783
937
 
784
- const queueLength = mutateMicrotaskQueue.length;
785
- mutateMicrotaskQueue.length = 0;
938
+ const { promise, resolve } = Promise.withResolvers<QueryRows>();
939
+ const typedPromise = promise as LoadingPromise["promise"];
940
+ typedPromise.status = "pending";
786
941
 
787
- // Don't process any mutations if there was a validation error.
788
- // All mutations within a queue run as a single transaction.
789
- if (changes.length !== queueLength) {
790
- return;
791
- }
942
+ loadingPromisesByQuery.set(query, {
943
+ promise: typedPromise,
944
+ resolve,
945
+ releaseOnResolve: false,
946
+ });
947
+
948
+ queryBatch.push(query);
792
949
 
793
- const onCompleteIds = onCompletes.map(onCompleteCallbacks.register);
794
- loadingPromises.releaseUnsubscribedOnMutation();
950
+ return typedPromise as Promise<QueryRows<R>>;
951
+ };
795
952
 
796
- if (!isNonEmptyArray(changes)) return;
953
+ const getQueryRows = <R extends Row>(query: Query<S, R>): QueryRows<R> => {
954
+ assertNotDisposed(moved);
797
955
 
798
- dbWorker.postMessage({
799
- type: "mutate",
800
- tabId: getTabId(),
801
- changes,
802
- onCompleteIds,
803
- subscribedQueries: subscribedQueries.get(),
804
- });
956
+ return (rowsByQueryMapStore.get().get(query) ??
957
+ emptyArray) as QueryRows<R>;
805
958
  };
806
959
 
807
- const evolu: InternalEvoluInstance = {
808
- subscribeError: errorStore.subscribe,
809
- getError: errorStore.get,
810
-
811
- createQuery,
812
-
813
- loadQuery: <R extends Row>(query: Query<R>): Promise<QueryRows<R>> => {
814
- const { promise, isNew } = loadingPromises.get(query);
815
-
816
- if (isNew) {
817
- loadQueryMicrotaskQueue.push(query);
818
- if (loadQueryMicrotaskQueue.length === 1) {
819
- queueMicrotask(() => {
820
- const queries = dedupeArray(loadQueryMicrotaskQueue);
821
- loadQueryMicrotaskQueue.length = 0;
822
- assertNonEmptyReadonlyArray(queries);
823
- deps.console.log("[evolu]", "loadQuery", { queries });
824
- dbWorker.postMessage({
825
- type: "query",
826
- tabId: getTabId(),
827
- queries,
828
- });
829
- });
830
- }
831
- }
960
+ const useOwner = (
961
+ owner: ReadonlyOwner | Owner,
962
+ ownerTransports?: NonEmptyReadonlyArray<OwnerTransport>,
963
+ ): UnuseOwner => {
964
+ assertNotDisposed(moved);
965
+
966
+ const effectiveTransports = ownerTransports ?? transports;
967
+ assertNonEmptyReadonlyArray(
968
+ effectiveTransports,
969
+ "useOwner requires explicit non-empty transports when config.transports is empty.",
970
+ );
971
+
972
+ const syncOwner: SyncOwner = {
973
+ owner,
974
+ transports: effectiveTransports,
975
+ };
832
976
 
833
- return promise;
834
- },
977
+ useOwnerBatch.push({
978
+ owner: syncOwner,
979
+ action: "add",
980
+ });
835
981
 
836
- loadQueries: <R extends Row, Q extends Queries<R>>(
837
- queries: [...Q],
838
- ): [...QueriesToQueryRowsPromises<Q>] =>
839
- queries.map(evolu.loadQuery) as [...QueriesToQueryRowsPromises<Q>],
982
+ let isUsed = true;
840
983
 
841
- subscribeQuery: (query) => (listener) => {
842
- // Call the listener only if the result has been changed.
843
- let previousRows: unknown = null;
844
- const unsubscribe = subscribedQueries.subscribe(query)(() => {
845
- const rows = evolu.getQueryRows(query);
846
- if (previousRows === rows) return;
847
- previousRows = rows;
848
- listener();
984
+ return () => {
985
+ assert(isUsed, "UnuseOwner can be called only once.");
986
+ isUsed = false;
987
+ useOwnerBatch.push({
988
+ owner: syncOwner,
989
+ action: "remove",
849
990
  });
850
- return () => {
851
- previousRows = null;
852
- unsubscribe();
853
- };
854
- },
991
+ };
992
+ };
855
993
 
856
- getQueryRows: <R extends Row>(query: Query<R>): QueryRows<R> =>
857
- (rowsStore.get().get(query) ?? emptyRows) as QueryRows<R>,
994
+ const moved = stack.move();
858
995
 
859
- appOwner,
996
+ if (isNonEmptyArray(transports)) useOwner(appOwner);
860
997
 
861
- // TODO: Update it for the owner-api
862
- // subscribeSyncState: syncStore.subscribe,
863
- // getSyncState: syncStore.get,
998
+ return ok({
999
+ name,
1000
+ appOwner,
864
1001
 
865
1002
  insert: createMutation("insert"),
866
1003
  update: createMutation("update"),
867
1004
  upsert: createMutation("upsert"),
868
1005
 
869
- resetAppOwner: (options) => {
870
- const { promise, resolve } = Promise.withResolvers<undefined>();
871
- const onCompleteId = onCompleteCallbacks.register(resolve);
872
- dbWorker.postMessage({
873
- type: "reset",
874
- onCompleteId,
875
- reload: options?.reload ?? true,
876
- });
877
- return promise;
878
- },
879
-
880
- restoreAppOwner: (mnemonic, options) => {
881
- const { promise, resolve } = Promise.withResolvers<undefined>();
882
- const onCompleteId = onCompleteCallbacks.register(resolve);
883
- dbWorker.postMessage({
884
- type: "reset",
885
- onCompleteId,
886
- reload: options?.reload ?? true,
887
- restore: { mnemonic, dbSchema },
888
- });
889
- return promise;
890
- },
891
-
892
- reloadApp: () => {
893
- deps.reloadApp(reloadUrl);
894
- },
895
-
896
- ensureSchema: (schema) => {
897
- mutationTypesCache.clear();
898
- const dbSchema = evoluSchemaToDbSchema(schema);
899
- dbWorker.postMessage({ type: "ensureDbSchema", dbSchema });
900
- },
901
-
902
- exportDatabase: () => {
903
- const { promise, resolve } =
904
- Promise.withResolvers<Uint8Array<ArrayBuffer>>();
905
- const onCompleteId = exportCallbacks.register(resolve);
906
- dbWorker.postMessage({ type: "export", onCompleteId });
907
- return promise;
908
- },
909
-
910
- useOwner: (owner) => {
911
- const scheduleOwnerQueueProcessing = () => {
912
- if (useOwnerMicrotaskQueue.length !== 1) return;
913
- queueMicrotask(() => {
914
- const queue = [...useOwnerMicrotaskQueue];
915
- useOwnerMicrotaskQueue.length = 0;
1006
+ loadQuery,
1007
+ loadQueries: <Q extends Queries<S>>(
1008
+ queries: [...Q],
1009
+ ): [...QueriesToQueryRowsPromises<Q>] =>
1010
+ queries.map((query) => loadQuery(query)) as [
1011
+ ...QueriesToQueryRowsPromises<Q>,
1012
+ ],
916
1013
 
917
- const result: Array<[SyncOwner, boolean, Uint8Array]> = [];
918
- const skipIndices = new Set<number>();
1014
+ subscribeQuery: (query) => (listener) => {
1015
+ assertNotDisposed(moved);
919
1016
 
920
- for (let i = 0; i < queue.length; i++) {
921
- if (skipIndices.has(i)) continue;
1017
+ subscribedQueriesRefCount.increment(query);
1018
+ let isSubscribed = true;
922
1019
 
923
- const [currentOwner, currentUse, currentOwnerSerialized] =
924
- queue[i];
1020
+ let previousRows: unknown = null;
925
1021
 
926
- // Look for opposite action with same owner
927
- for (let j = i + 1; j < queue.length; j++) {
928
- if (skipIndices.has(j)) continue;
1022
+ const unsubscribe = rowsByQueryMapStore.subscribe(() => {
1023
+ const rows = getQueryRows(query);
1024
+ if (previousRows === rows) return;
1025
+ previousRows = rows;
1026
+ listener();
1027
+ });
929
1028
 
930
- const [, otherUse, otherOwnerSerialized] = queue[j];
1029
+ return () => {
1030
+ assert(
1031
+ isSubscribed,
1032
+ "subscribeQuery unsubscribe can be called only once.",
1033
+ );
1034
+ isSubscribed = false;
931
1035
 
932
- if (
933
- currentUse !== otherUse &&
934
- eqArrayNumber(currentOwnerSerialized, otherOwnerSerialized)
935
- ) {
936
- // Found cancel-out pair, skip both
937
- skipIndices.add(i).add(j);
938
- break;
939
- }
940
- }
1036
+ previousRows = null;
1037
+ unsubscribe();
941
1038
 
942
- if (!skipIndices.has(i)) {
943
- result.push([currentOwner, currentUse, currentOwnerSerialized]);
944
- }
945
- }
1039
+ if (moved.disposed) return;
946
1040
 
947
- for (const [owner, use] of result) {
948
- dbWorker.postMessage({ type: "useOwner", owner, use });
949
- }
950
- });
1041
+ subscribedQueriesRefCount.decrement(query);
951
1042
  };
1043
+ },
1044
+ getQueryRows,
952
1045
 
953
- useOwnerMicrotaskQueue.push([owner, true, pack(owner)]);
954
- scheduleOwnerQueueProcessing();
955
-
956
- const unuse = () => {
957
- useOwnerMicrotaskQueue.push([owner, false, pack(owner)]);
958
- scheduleOwnerQueueProcessing();
959
- };
1046
+ exportDatabase: () => {
1047
+ assertNotDisposed(moved);
960
1048
 
961
- return unuse;
1049
+ if (!exportDatabasePending) {
1050
+ exportDatabasePending =
1051
+ Promise.withResolvers<Uint8Array<ArrayBuffer>>();
1052
+ postMessage({ type: "Export" });
1053
+ }
1054
+ return exportDatabasePending.promise;
962
1055
  },
963
1056
 
964
- /** Disposal is not implemented yet. */
965
- [Symbol.dispose]: () => {
966
- throw new Error("Evolu instance disposal is not yet implemented");
1057
+ deleteDatabase: () => {
1058
+ assertNotDisposed(moved);
1059
+ todo();
967
1060
  },
968
- };
969
-
970
- return evolu;
971
- };
972
-
973
- export const createQuery = <R extends Row>(
974
- queryCallback: Parameters<CreateQuery<EvoluSchema>>[0],
975
- options?: Parameters<CreateQuery<EvoluSchema>>[1],
976
- ): Query<R> => {
977
- const compiledQuery = queryCallback(kysely as IntentionalNever).compile();
978
-
979
- if (isSqlMutation(compiledQuery.sql))
980
- throw new Error(
981
- "SQL mutation (INSERT, UPDATE, DELETE, etc.) isn't allowed in the Evolu `createQuery` function. Kysely suggests it because there is no read-only Kysely yet, and removing such an API is not possible. For mutations, use Evolu Mutation API.",
982
- );
983
-
984
- return serializeQuery({
985
- sql: compiledQuery.sql as SafeSql,
986
- parameters: compiledQuery.parameters as NonNullable<
987
- SqliteQuery["parameters"]
988
- >,
989
- ...(options && { options }),
990
- });
991
- };
992
1061
 
993
- interface LoadingPromises {
994
- get: <R extends Row>(
995
- query: Query<R>,
996
- ) => {
997
- readonly promise: Promise<QueryRows<R>>;
998
- readonly isNew: boolean;
999
- };
1000
-
1001
- resolve: (query: Query, rows: ReadonlyArray<Row>) => void;
1002
-
1003
- releaseUnsubscribedOnMutation: () => void;
1062
+ deleteOwner: (owner) => {
1063
+ assertNotDisposed(moved);
1064
+ void owner;
1065
+ todo();
1066
+ },
1004
1067
 
1005
- getQueries: () => ReadonlyArray<Query>;
1006
- }
1068
+ useOwner,
1007
1069
 
1008
- interface LoadingPromise {
1009
- /** Promise with props for the React use hook. */
1010
- promise: Promise<QueryRows> & {
1011
- status?: "pending" | "fulfilled" | "rejected";
1012
- value?: QueryRows;
1013
- reason?: unknown;
1070
+ [Symbol.asyncDispose]: () => {
1071
+ console.info("dispose");
1072
+ return moved.disposeAsync();
1073
+ },
1074
+ } as Evolu<S>);
1014
1075
  };
1015
- resolve: (rows: QueryRows) => void;
1016
- releaseOnResolve: boolean;
1017
- }
1018
-
1019
- const createLoadingPromises = (
1020
- subscribedQueries: SubscribedQueries,
1021
- ): LoadingPromises => {
1022
- const loadingPromiseMap = new Map<Query, LoadingPromise>();
1023
-
1024
- return {
1025
- get: <R extends Row>(
1026
- query: Query<R>,
1027
- ): {
1028
- readonly promise: Promise<QueryRows<R>>;
1029
- readonly isNew: boolean;
1030
- } => {
1031
- let loadingPromise = loadingPromiseMap.get(query);
1032
- const isNew = !loadingPromise;
1033
- if (!loadingPromise) {
1034
- const { promise, resolve } = Promise.withResolvers<QueryRows>();
1035
- loadingPromise = { resolve, promise, releaseOnResolve: false };
1036
- loadingPromiseMap.set(query, loadingPromise);
1037
- }
1038
- return {
1039
- promise: loadingPromise.promise as Promise<QueryRows<R>>,
1040
- isNew,
1041
- };
1042
- },
1043
-
1044
- resolve: (query, rows) => {
1045
- const loadingPromise = loadingPromiseMap.get(query);
1046
- if (!loadingPromise) return;
1047
-
1048
- if (loadingPromise.promise.status !== "fulfilled") {
1049
- loadingPromise.resolve(rows);
1050
- } else {
1051
- loadingPromise.promise = Promise.resolve(rows);
1052
- }
1053
1076
 
1054
- // Set status and value fields for React's `use` Hook to unwrap synchronously.
1055
- // While undocumented in React docs, React still uses these properties internally,
1056
- // and Evolu's own promise caching logic depends on checking `promise.status`.
1057
- // https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md
1058
- void Object.assign(loadingPromise.promise, {
1059
- status: "fulfilled",
1060
- value: rows,
1061
- });
1062
-
1063
- if (loadingPromise.releaseOnResolve) {
1064
- loadingPromiseMap.delete(query);
1065
- }
1066
- },
1067
-
1068
- releaseUnsubscribedOnMutation: () => {
1069
- [...loadingPromiseMap.entries()]
1070
- .filter(([query]) => !subscribedQueries.has(query))
1071
- .forEach(([query, loadingPromise]) => {
1072
- if (loadingPromise.promise.status === "fulfilled") {
1073
- loadingPromiseMap.delete(query);
1074
- } else {
1075
- loadingPromise.releaseOnResolve = true;
1076
- }
1077
- });
1078
- },
1079
-
1080
- getQueries: () => Array.from(loadingPromiseMap.keys()),
1081
- };
1082
- };
1077
+ // case "onReset": {
1078
+ // if (message.reload) {
1079
+ // deps.reloadApp(reloadUrl);
1080
+ // } else {
1081
+ // onCompleteCallbacks.execute(message.onCompleteId);
1082
+ // }
1083
+ // break;
1084
+ // }
1085
+
1086
+ // resetAppOwner: (_options) => {
1087
+ // const { promise, resolve } = Promise.withResolvers<undefined>();
1088
+ // const _onCompleteId = onCompleteCallbacks.register(resolve);
1089
+ // // dbWorker.postMessage({
1090
+ // // type: "reset",
1091
+ // // onCompleteId,
1092
+ // // reload: options?.reload ?? true,
1093
+ // // });
1094
+ // return promise;
1095
+ // },
1096
+
1097
+ // restoreAppOwner: (_mnemonic, _options) => {
1098
+ // const { promise, resolve } = Promise.withResolvers<undefined>();
1099
+ // const _onCompleteId = onCompleteCallbacks.register(resolve);
1100
+ // // dbWorker.postMessage({
1101
+ // // type: "reset",
1102
+ // // onCompleteId,
1103
+ // // reload: options?.reload ?? true,
1104
+ // // restore: { mnemonic, sqliteSchema },
1105
+ // // });
1106
+ // return promise;
1107
+ // },
1108
+
1109
+ // reloadApp: () => {
1110
+ // // TODO:
1111
+ // // deps.reloadApp(reloadUrl);
1112
+ // },
1113
+
1114
+ // ensureSchema: (schema) => {
1115
+ // mutationTypesCache.clear();
1116
+ // const sqliteSchema = evoluSchemaToSqliteSchema(schema);
1117
+ // dbWorker.postMessage({ type: "ensureSqliteSchema", sqliteSchema });
1118
+ // },
1119
+
1120
+ // /**
1121
+ // * Delete {@link AppOwner} and all their data from the current device. After
1122
+ // * the deletion, Evolu will purge the application state. For browsers, this
1123
+ // * will reload all tabs using Evolu. For native apps, it will restart the
1124
+ // * app.
1125
+ // *
1126
+ // * Reloading can be turned off via options if you want to provide a different
1127
+ // * UX.
1128
+ // */
1129
+ // readonly resetAppOwner: (options?: {
1130
+ // readonly reload?: boolean;
1131
+ // }) => Promise<void>;
1132
+
1133
+ // /**
1134
+ // * Restore {@link AppOwner} with all their synced data. It uses
1135
+ // * {@link Evolu.resetAppOwner}, so be careful.
1136
+ // */
1137
+ // readonly restoreAppOwner: (
1138
+ // mnemonic: Mnemonic,
1139
+ // options?: {
1140
+ // readonly reload?: boolean;
1141
+ // },
1142
+ // ) => Promise<void>;