@evolu/common 7.4.0 → 8.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +4 -61
  2. package/dist/src/Array.d.ts +343 -102
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +181 -85
  5. package/dist/src/Assert.d.ts +57 -11
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +47 -11
  8. package/dist/src/BigInt.d.ts +6 -1
  9. package/dist/src/BigInt.d.ts.map +1 -1
  10. package/dist/src/BigInt.js +5 -0
  11. package/dist/src/Brand.d.ts +40 -12
  12. package/dist/src/Brand.d.ts.map +1 -1
  13. package/dist/src/Brand.js +5 -0
  14. package/dist/src/Buffer.d.ts +6 -1
  15. package/dist/src/Buffer.d.ts.map +1 -1
  16. package/dist/src/Buffer.js +7 -2
  17. package/dist/src/Cache.d.ts +10 -2
  18. package/dist/src/Cache.d.ts.map +1 -1
  19. package/dist/src/Cache.js +6 -0
  20. package/dist/src/Callbacks.d.ts +10 -7
  21. package/dist/src/Callbacks.d.ts.map +1 -1
  22. package/dist/src/Callbacks.js +23 -13
  23. package/dist/src/Console.d.ts +361 -69
  24. package/dist/src/Console.d.ts.map +1 -1
  25. package/dist/src/Console.js +217 -117
  26. package/dist/src/Crypto.d.ts +70 -21
  27. package/dist/src/Crypto.d.ts.map +1 -1
  28. package/dist/src/Crypto.js +72 -27
  29. package/dist/src/Eq.d.ts +22 -2
  30. package/dist/src/Eq.d.ts.map +1 -1
  31. package/dist/src/Eq.js +21 -1
  32. package/dist/src/Error.d.ts +23 -12
  33. package/dist/src/Error.d.ts.map +1 -1
  34. package/dist/src/Error.js +27 -11
  35. package/dist/src/Function.d.ts +128 -56
  36. package/dist/src/Function.d.ts.map +1 -1
  37. package/dist/src/Function.js +118 -14
  38. package/dist/src/Identicon.d.ts +5 -0
  39. package/dist/src/Identicon.d.ts.map +1 -1
  40. package/dist/src/Identicon.js +6 -1
  41. package/dist/src/Lookup.d.ts +160 -0
  42. package/dist/src/Lookup.d.ts.map +1 -0
  43. package/dist/src/Lookup.js +192 -0
  44. package/dist/src/Microtask.d.ts +21 -0
  45. package/dist/src/Microtask.d.ts.map +1 -0
  46. package/dist/src/Microtask.js +37 -0
  47. package/dist/src/Number.d.ts +18 -4
  48. package/dist/src/Number.d.ts.map +1 -1
  49. package/dist/src/Number.js +23 -1
  50. package/dist/src/Object.d.ts +110 -5
  51. package/dist/src/Object.d.ts.map +1 -1
  52. package/dist/src/Object.js +108 -5
  53. package/dist/src/Option.d.ts +58 -0
  54. package/dist/src/Option.d.ts.map +1 -0
  55. package/dist/src/Option.js +43 -0
  56. package/dist/src/Order.d.ts +5 -0
  57. package/dist/src/Order.d.ts.map +1 -1
  58. package/dist/src/Order.js +8 -3
  59. package/dist/src/Platform.d.ts +36 -2
  60. package/dist/src/Platform.d.ts.map +1 -1
  61. package/dist/src/Platform.js +10 -6
  62. package/dist/src/Polyfills.d.ts +27 -0
  63. package/dist/src/Polyfills.d.ts.map +1 -0
  64. package/dist/src/Polyfills.js +299 -0
  65. package/dist/src/Random.d.ts +18 -7
  66. package/dist/src/Random.d.ts.map +1 -1
  67. package/dist/src/Random.js +9 -7
  68. package/dist/src/Redacted.d.ts +7 -4
  69. package/dist/src/Redacted.d.ts.map +1 -1
  70. package/dist/src/Redacted.js +5 -0
  71. package/dist/src/Ref.d.ts +31 -16
  72. package/dist/src/Ref.d.ts.map +1 -1
  73. package/dist/src/Ref.js +35 -7
  74. package/dist/src/RefCount.d.ts +62 -0
  75. package/dist/src/RefCount.d.ts.map +1 -0
  76. package/dist/src/RefCount.js +83 -0
  77. package/dist/src/Relation.d.ts +84 -29
  78. package/dist/src/Relation.d.ts.map +1 -1
  79. package/dist/src/Relation.js +83 -105
  80. package/dist/src/Resource.d.ts +263 -0
  81. package/dist/src/Resource.d.ts.map +1 -0
  82. package/dist/src/Resource.js +389 -0
  83. package/dist/src/Result.d.ts +390 -374
  84. package/dist/src/Result.d.ts.map +1 -1
  85. package/dist/src/Result.js +142 -70
  86. package/dist/src/Schedule.d.ts +953 -0
  87. package/dist/src/Schedule.d.ts.map +1 -0
  88. package/dist/src/Schedule.js +1199 -0
  89. package/dist/src/Set.d.ts +181 -0
  90. package/dist/src/Set.d.ts.map +1 -0
  91. package/dist/src/Set.js +137 -0
  92. package/dist/src/Skiplist.d.ts +6 -1
  93. package/dist/src/Skiplist.d.ts.map +1 -1
  94. package/dist/src/Skiplist.js +5 -0
  95. package/dist/src/Sqlite.d.ts +160 -60
  96. package/dist/src/Sqlite.d.ts.map +1 -1
  97. package/dist/src/Sqlite.js +290 -190
  98. package/dist/src/Store.d.ts +36 -28
  99. package/dist/src/Store.d.ts.map +1 -1
  100. package/dist/src/Store.js +55 -15
  101. package/dist/src/String.d.ts +5 -0
  102. package/dist/src/String.d.ts.map +1 -1
  103. package/dist/src/String.js +5 -0
  104. package/dist/src/Task.d.ts +2263 -423
  105. package/dist/src/Task.d.ts.map +1 -1
  106. package/dist/src/Task.js +1488 -348
  107. package/dist/src/Test.d.ts +122 -0
  108. package/dist/src/Test.d.ts.map +1 -0
  109. package/dist/src/Test.js +66 -0
  110. package/dist/src/Time.d.ts +201 -48
  111. package/dist/src/Time.d.ts.map +1 -1
  112. package/dist/src/Time.js +190 -86
  113. package/dist/src/Tracer.d.ts +48 -0
  114. package/dist/src/Tracer.d.ts.map +1 -0
  115. package/dist/src/Tracer.js +6 -0
  116. package/dist/src/Type.d.ts +582 -209
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +362 -240
  119. package/dist/src/Types.d.ts +160 -6
  120. package/dist/src/Types.d.ts.map +1 -1
  121. package/dist/src/Types.js +22 -2
  122. package/dist/src/WebSocket.d.ts +113 -58
  123. package/dist/src/WebSocket.d.ts.map +1 -1
  124. package/dist/src/WebSocket.js +210 -118
  125. package/dist/src/Worker.d.ts +245 -60
  126. package/dist/src/Worker.d.ts.map +1 -1
  127. package/dist/src/Worker.js +220 -64
  128. package/dist/src/index.d.ts +25 -3
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +19 -3
  131. package/dist/src/local-first/Db.d.ts +24 -213
  132. package/dist/src/local-first/Db.d.ts.map +1 -1
  133. package/dist/src/local-first/Db.js +622 -293
  134. package/dist/src/local-first/Error.d.ts +12 -0
  135. package/dist/src/local-first/Error.d.ts.map +1 -0
  136. package/dist/src/local-first/Error.js +6 -0
  137. package/dist/src/local-first/Evolu.d.ts +330 -285
  138. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  139. package/dist/src/local-first/Evolu.js +472 -402
  140. package/dist/src/local-first/LocalAuth.d.ts +10 -10
  141. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  142. package/dist/src/local-first/LocalAuth.js +6 -1
  143. package/dist/src/local-first/Owner.d.ts +70 -27
  144. package/dist/src/local-first/Owner.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.js +27 -14
  146. package/dist/src/local-first/Protocol.d.ts +58 -50
  147. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  148. package/dist/src/local-first/Protocol.js +253 -155
  149. package/dist/src/local-first/Query.d.ts +133 -43
  150. package/dist/src/local-first/Query.d.ts.map +1 -1
  151. package/dist/src/local-first/Query.js +139 -90
  152. package/dist/src/local-first/Relay.d.ts +18 -38
  153. package/dist/src/local-first/Relay.d.ts.map +1 -1
  154. package/dist/src/local-first/Relay.js +59 -171
  155. package/dist/src/local-first/Schema.d.ts +139 -144
  156. package/dist/src/local-first/Schema.d.ts.map +1 -1
  157. package/dist/src/local-first/Schema.js +78 -163
  158. package/dist/src/local-first/Shared.d.ts +125 -0
  159. package/dist/src/local-first/Shared.d.ts.map +1 -0
  160. package/dist/src/local-first/Shared.js +569 -0
  161. package/dist/src/local-first/Storage.d.ts +50 -53
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +121 -173
  164. package/dist/src/local-first/Timestamp.d.ts +28 -35
  165. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  166. package/dist/src/local-first/Timestamp.js +24 -25
  167. package/dist/src/local-first/index.d.ts +2 -15
  168. package/dist/src/local-first/index.d.ts.map +1 -1
  169. package/dist/src/local-first/index.js +2 -15
  170. package/package.json +36 -26
  171. package/src/Array.ts +467 -106
  172. package/src/Assert.ts +87 -12
  173. package/src/BigInt.ts +7 -1
  174. package/src/Brand.ts +41 -12
  175. package/src/Buffer.ts +10 -4
  176. package/src/Cache.ts +11 -2
  177. package/src/Callbacks.ts +33 -23
  178. package/src/Console.ts +567 -178
  179. package/src/Crypto.ts +111 -62
  180. package/src/Eq.ts +30 -3
  181. package/src/Error.ts +50 -34
  182. package/src/Function.ts +140 -68
  183. package/src/Identicon.ts +7 -1
  184. package/src/Lookup.ts +415 -0
  185. package/src/Microtask.ts +58 -0
  186. package/src/Number.ts +40 -7
  187. package/src/Object.ts +155 -16
  188. package/src/Option.ts +74 -0
  189. package/src/Order.ts +11 -3
  190. package/src/Platform.ts +43 -7
  191. package/src/Polyfills.ts +465 -0
  192. package/src/Random.ts +25 -11
  193. package/src/Redacted.ts +8 -4
  194. package/src/Ref.ts +72 -21
  195. package/src/RefCount.ts +170 -0
  196. package/src/Relation.ts +188 -136
  197. package/src/Resource.ts +864 -0
  198. package/src/Result.ts +548 -381
  199. package/src/Schedule.ts +1471 -0
  200. package/src/Set.ts +247 -0
  201. package/src/Skiplist.ts +7 -1
  202. package/src/Sqlite.ts +409 -304
  203. package/src/Store.ts +94 -48
  204. package/src/String.ts +6 -0
  205. package/src/Task.ts +3685 -740
  206. package/src/Test.ts +162 -0
  207. package/src/Time.ts +348 -145
  208. package/src/Tracer.ts +54 -0
  209. package/src/Type.ts +1064 -630
  210. package/src/Types.ts +202 -7
  211. package/src/WebSocket.ts +311 -178
  212. package/src/Worker.ts +527 -147
  213. package/src/index.ts +74 -3
  214. package/src/local-first/Db.ts +871 -597
  215. package/src/local-first/Error.ts +17 -0
  216. package/src/local-first/Evolu.ts +884 -823
  217. package/src/local-first/LocalAuth.ts +10 -10
  218. package/src/local-first/Owner.ts +94 -42
  219. package/src/local-first/Protocol.ts +440 -303
  220. package/src/local-first/Query.ts +243 -194
  221. package/src/local-first/Relay.ts +91 -252
  222. package/src/local-first/Schema.ts +284 -394
  223. package/src/local-first/Shared.ts +932 -0
  224. package/src/local-first/Storage.ts +209 -282
  225. package/src/local-first/Timestamp.ts +38 -45
  226. package/src/local-first/index.ts +2 -16
  227. package/LICENSE +0 -21
  228. package/dist/src/Instances.d.ts +0 -34
  229. package/dist/src/Instances.d.ts.map +0 -1
  230. package/dist/src/Instances.js +0 -44
  231. package/dist/src/Resources.d.ts +0 -118
  232. package/dist/src/Resources.d.ts.map +0 -1
  233. package/dist/src/Resources.js +0 -197
  234. package/dist/src/local-first/Platform.d.ts +0 -25
  235. package/dist/src/local-first/Platform.d.ts.map +0 -1
  236. package/dist/src/local-first/Platform.js +0 -1
  237. package/dist/src/local-first/Public.d.ts +0 -22
  238. package/dist/src/local-first/Public.d.ts.map +0 -1
  239. package/dist/src/local-first/Public.js +0 -15
  240. package/dist/src/local-first/PublicKysely.d.ts +0 -148
  241. package/dist/src/local-first/PublicKysely.d.ts.map +0 -1
  242. package/dist/src/local-first/PublicKysely.js +0 -184
  243. package/dist/src/local-first/Sync.d.ts +0 -112
  244. package/dist/src/local-first/Sync.d.ts.map +0 -1
  245. package/dist/src/local-first/Sync.js +0 -529
  246. package/src/Instances.ts +0 -90
  247. package/src/Resources.ts +0 -367
  248. package/src/local-first/Platform.ts +0 -27
  249. package/src/local-first/Public.ts +0 -42
  250. package/src/local-first/PublicKysely.ts +0 -239
  251. package/src/local-first/Sync.ts +0 -960
@@ -1,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}.
302
+ *
303
+ * Only `id` is required, all other columns are optional.
131
304
  *
132
- * Evolu uses Kysely - the type-safe SQL query builder for TypeScript. See
133
- * https://kysely.dev.
305
+ * ### Example
306
+ *
307
+ * ```ts
308
+ * const { id } = evolu.update("todo", {
309
+ * id: todoId,
310
+ * title: NonEmptyString100.orThrow("Updated title"),
311
+ * });
134
312
  *
135
- * All this function does is compile the Kysely query and serialize it into a
136
- * unique string. Both operations are fast and cheap.
313
+ * // Soft delete
314
+ * evolu.update("todo", { id: todoId, isDeleted: sqliteTrue });
315
+ * ```
137
316
  *
138
- * For mutations, use {@link Evolu#insert}, {@link Evolu#update}, or
139
- * {@link Evolu#upsert}.
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,863 +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.
422
+ * Exports the SQLite database file.
226
423
  *
227
- * Note: With web-only deps, this promise will not resolve during SSR because
228
- * there is no AppOwner on the server.
229
- *
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.
245
- *
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.
435
+ * Delete the local SQLite database for this {@link Evolu} instance on the
436
+ * current device.
249
437
  *
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}.
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
253
442
  *
254
- * ### Example
255
- *
256
- * ```ts
257
- * const result = evolu.insert("todo", {
258
- * title: "Learn Evolu",
259
- * isCompleted: false,
260
- * });
261
- *
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.
290
- *
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.
452
+ * Delete all local data for a specific {@link Owner}.
294
453
  *
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
- * });
454
+ * This deletes all rows whose `ownerId` matches the provided owner and
455
+ * deletes that owner's local history as well.
307
456
  *
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}.
463
+ * Use an Owner for sync. Returns a {@link UnuseOwner}.
334
464
  *
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.
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.
339
468
  *
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.
469
+ * If {@link EvoluConfig.transports} is an empty array, this method must be
470
+ * called with explicit non-empty transports.
343
471
  *
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.
347
- *
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}.
505
+ * {@link ReadonlyStore} of {@link EvoluError} shared by all {@link Evolu}
506
+ * instances created from the same {@link createEvoluDeps} result.
431
507
  *
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.
434
- *
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
- */
454
- readonly useOwner: (owner: SyncOwner) => UnuseOwner;
455
- }
456
-
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<S extends EvoluSchema = EvoluSchema>
469
- extends Evolu<S> {
470
- /**
471
- * Ensure tables and columns defined in {@link EvoluSchema} exist in the
472
- * database. This function is for hot reloading.
473
531
  */
474
- readonly ensureSchema: (schema: EvoluSchema) => void;
532
+ readonly evoluError: ReadonlyStore<EvoluError | null>;
475
533
  }
476
534
 
477
- export type EvoluDeps = ConsoleDep &
478
- CreateDbWorkerDep &
479
- Partial<FlushSyncDep> &
480
- RandomBytesDep &
481
- ReloadAppDep &
482
- TimeDep;
483
-
484
- const evoluInstances = createInstances<SimpleName, InternalEvoluInstance>();
485
-
486
535
  /**
487
- * Unique identifier for the current browser tab or app instance, lazily
488
- * 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.
489
540
  */
490
- let tabId: Id | null = null;
541
+ export type EvoluDeps = EvoluPlatformDeps & EvoluErrorDep & Disposable;
491
542
 
492
543
  /**
493
- * Creates an {@link Evolu} instance for a platform configured with the specified
494
- * {@link EvoluSchema} and optional {@link EvoluConfig} providing a typed
495
- * interface for querying, mutating, and syncing your application's data.
496
- *
497
- * ### Example
498
- *
499
- * ```ts
500
- * const TodoId = id("Todo");
501
- * type TodoId = InferType<typeof TodoId>;
502
- *
503
- * const TodoCategoryId = id("TodoCategory");
504
- * type TodoCategoryId = InferType<typeof TodoCategoryId>;
505
- *
506
- * const NonEmptyString50 = maxLength(50, NonEmptyString);
507
- * type NonEmptyString50 = InferType<typeof NonEmptyString50>;
508
- *
509
- * const Schema = {
510
- * todo: {
511
- * id: TodoId,
512
- * title: NonEmptyString1000,
513
- * isCompleted: nullOr(SqliteBoolean),
514
- * categoryId: nullOr(TodoCategoryId),
515
- * },
516
- * todoCategory: {
517
- * id: TodoCategoryId,
518
- * name: NonEmptyString50,
519
- * },
520
- * };
544
+ * Platform-specific dependencies required to create {@link EvoluDeps}.
521
545
  *
522
- * const evolu = createEvolu(evoluReactDeps)(Schema);
523
- * ```
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.
524
559
  *
525
- * ### 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.
526
564
  *
527
- * `createEvolu` caches instances using {@link Instances} by {@link EvoluConfig}
528
- * name to enable hot reloading and prevent database corruption from multiple
529
- * connections. For testing, use unique instance names to ensure proper
530
- * isolation.
565
+ * Dispose it only during app shutdown.
531
566
  */
532
- export const createEvolu =
533
- (deps: EvoluDeps) =>
534
- <S extends EvoluSchema>(
535
- schema: ValidateSchema<S> extends never ? S : ValidateSchema<S>,
536
- config?: EvoluConfig,
537
- ): Evolu<S> =>
538
- evoluInstances.ensure(
539
- config?.name ?? defaultDbConfig.name,
540
- () => createEvoluInstance(deps)(schema as EvoluSchema, config),
541
- (evolu) => {
542
- // Hot reloading. Note that indexes are intentionally omitted.
543
- evolu.ensureSchema(schema as EvoluSchema);
544
- },
545
- ) as Evolu<S>;
546
-
547
- const createEvoluInstance =
548
- (deps: EvoluDeps) =>
549
- (schema: EvoluSchema, config?: EvoluConfig): InternalEvoluInstance => {
550
- deps.console.enabled = config?.enableLogging ?? false;
551
-
552
- const { indexes, reloadUrl = "/", ...partialDbConfig } = config ?? {};
553
-
554
- const dbConfig: DbConfig = { ...defaultDbConfig, ...partialDbConfig };
555
-
556
- deps.console.log("[evolu]", "createEvoluInstance", {
557
- name: dbConfig.name,
558
- });
559
-
560
- const errorStore = createStore<EvoluError | null>(null);
561
- 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;
562
585
 
563
- const { promise: appOwner, resolve: resolveAppOwner } =
564
- 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;
565
591
 
566
- if (config?.externalAppOwner) {
567
- resolveAppOwner(config.externalAppOwner);
592
+ default:
593
+ exhaustiveCheck(message);
568
594
  }
595
+ };
569
596
 
570
- // TODO: Update it for the owner-api
571
- 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
+ );
572
605
 
573
- const subscribedQueries = createSubscribedQueries(rowsStore);
574
- const loadingPromises = createLoadingPromises(subscribedQueries);
575
- const onCompleteCallbacks = createCallbacks(deps);
576
- const exportCallbacks = createCallbacks<Uint8Array<ArrayBuffer>>(deps);
606
+ const moved = stack.move();
577
607
 
578
- const dbWorker = deps.createDbWorker(dbConfig.name);
608
+ return {
609
+ ...deps,
610
+ evoluError,
611
+ [Symbol.dispose]: () => moved.dispose(),
612
+ };
613
+ };
579
614
 
580
- const getTabId = () => {
581
- tabId ??= createId(deps);
582
- return tabId;
583
- };
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;
584
631
 
585
- // Worker responses are delivered to all tabs. Each case must handle this
586
- // properly (e.g., AppOwner promise resolves only once, tabId filtering).
587
- dbWorker.onMessage((message) => {
588
- switch (message.type) {
589
- case "onError": {
590
- errorStore.set(message.error);
591
- break;
592
- }
632
+ const name = Name.orThrow(`${appName}-${createIdFromString(appOwner.id)}`);
633
+ const console = run.deps.console.child(name).child("Evolu");
634
+ console.info("createEvolu");
593
635
 
594
- case "onGetAppOwner": {
595
- resolveAppOwner(message.appOwner);
596
- break;
597
- }
636
+ await using stack = new AsyncDisposableStack();
598
637
 
599
- case "onQueryPatches": {
600
- 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
+ }
601
660
 
602
- const state = rowsStore.get();
603
- const nextState = new Map([
604
- ...state,
605
- ...message.queryPatches.map(
606
- ({ query, patches }): [Query, ReadonlyArray<Row>] => [
607
- query,
608
- applyPatches(patches, state.get(query) ?? emptyRows),
609
- ],
610
- ),
611
- ]);
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
+ );
612
675
 
613
- for (const { query } of message.queryPatches) {
614
- loadingPromises.resolve(query, nextState.get(query) ?? emptyRows);
615
- }
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
+ }
616
690
 
617
- if (deps.flushSync && message.onCompleteIds.length > 0) {
618
- deps.flushSync(() => {
619
- rowsStore.set(nextState);
620
- });
621
- } else {
622
- rowsStore.set(nextState);
623
- }
691
+ /** See {@link LoadingPromise.promise}. */
692
+ void Object.assign(loadingPromise.promise, {
693
+ status: "fulfilled",
694
+ value: rows,
695
+ });
696
+ };
624
697
 
625
- for (const id of message.onCompleteIds) {
626
- onCompleteCallbacks.execute(id);
627
- }
628
- 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;
629
714
  }
715
+ }
716
+ };
630
717
 
631
- case "refreshQueries": {
632
- if (message.tabId && message.tabId === getTabId()) return;
718
+ const onMutateCompleteCallbacks = stack.use(createCallbacks(run.deps));
633
719
 
634
- const loadingPromisesQueries = loadingPromises.getQueries();
635
- loadingPromises.releaseUnsubscribedOnMutation();
720
+ let exportDatabasePending = null as PromiseWithResolvers<
721
+ Uint8Array<ArrayBuffer>
722
+ > | null;
636
723
 
637
- const queries = dedupeArray([
638
- ...loadingPromisesQueries,
639
- ...subscribedQueries.get(),
640
- ]);
724
+ stack.defer(() => {
725
+ exportDatabasePending?.reject({ type: "EvoluDisposedError" });
726
+ exportDatabasePending = null;
727
+ });
641
728
 
642
- if (isNonEmptyReadonlyArray(queries)) {
643
- dbWorker.postMessage({ type: "query", tabId: getTabId(), queries });
644
- }
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
+ );
645
749
 
646
- break;
647
- }
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
+ );
648
761
 
649
- case "onReset": {
650
- if (message.reload) {
651
- deps.reloadApp(reloadUrl);
652
- } else {
653
- onCompleteCallbacks.execute(message.onCompleteId);
654
- }
655
- break;
656
- }
762
+ const useOwnerBatch = stack.use(
763
+ createMicrotaskBatch<
764
+ ExtractType<EvoluInput, "UseOwner">["actions"][number]
765
+ >((actions) => postMessage({ type: "UseOwner", actions })),
766
+ );
657
767
 
658
- case "onExport": {
659
- exportCallbacks.execute(
660
- message.onCompleteId,
661
- message.file as Uint8Array<ArrayBuffer>,
662
- );
663
- break;
664
- }
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);
665
789
 
666
- default:
667
- exhaustiveCheck(message);
668
- }
669
- });
790
+ for (const [query, patches] of message.patchesByQuery) {
791
+ nextRowsByQueryMap.set(
792
+ query,
793
+ applyPatches(patches, state.get(query) ?? emptyArray),
794
+ );
795
+ }
670
796
 
671
- const dbSchema = evoluSchemaToDbSchema(schema, indexes);
797
+ for (const query of message.patchesByQuery.keys()) {
798
+ const loadingPromise = loadingPromisesByQuery.get(query);
799
+ if (!loadingPromise) continue;
672
800
 
673
- const mutationTypesCache = new Map<
674
- MutationKind,
675
- Map<string, ValidMutationSize<any>>
676
- >();
801
+ const rows = nextRowsByQueryMap.get(query);
802
+ assert(rows, "Expected patched query rows to exist.");
677
803
 
678
- // Lazy create mutation Types like this: `insertable(Schema.todo)`
679
- const getMutationType = (table: string, kind: MutationKind) => {
680
- let types = mutationTypesCache.get(kind);
681
- if (!types) {
682
- types = new Map();
683
- mutationTypesCache.set(kind, types);
684
- }
685
- let type = types.get(table);
686
- if (!type) {
687
- type = { insert: insertable, update: updateable, upsert: upsertable }[
688
- kind
689
- ](schema[table]);
690
- types.set(table, type);
691
- }
692
- return type;
693
- };
804
+ fulfillLoadingPromise(loadingPromise, rows);
694
805
 
695
- 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
+ }
696
815
 
697
- // We can't use `init` to get AppOwner because `init` runs only once per n tabs.
698
- 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
+ }
699
823
 
700
- const loadQueryMicrotaskQueue: Array<Query> = [];
824
+ for (const onCompleteId of message.onCompleteIds) {
825
+ onMutateCompleteCallbacks.execute(onCompleteId);
826
+ }
827
+ break;
828
+ }
701
829
 
702
- const mutateMicrotaskQueue: Array<
703
- [MutationChange | null, MutationOptions["onComplete"] | undefined]
704
- > = [];
830
+ case "RefreshQueries": {
831
+ releaseUnsubscribedLoadingPromises();
705
832
 
706
- const useOwnerMicrotaskQueue: Array<[SyncOwner, boolean, Uint8Array]> = [];
833
+ const queries = new Set<Query>([
834
+ ...loadingPromisesByQuery.keys(),
835
+ ...subscribedQueriesRefCount.keys(),
836
+ ]);
707
837
 
708
- const createMutation =
709
- <Kind extends MutationKind>(kind: Kind) =>
710
- <TableName extends keyof typeof schema>(
711
- table: TableName,
712
- props: InferInput<
713
- ObjectType<MutationMapping<(typeof schema)[TableName], Kind>>
714
- >,
715
- options?: MutationOptions,
716
- ): Result<
717
- { readonly id: InferType<(typeof schema)[TableName]["id"]> },
718
- | ValidMutationSizeError
719
- | InferErrors<
720
- ObjectType<MutationMapping<(typeof schema)[TableName], Kind>>
721
- >
722
- > => {
723
- const result = getMutationType(table, kind).fromUnknown(props);
724
-
725
- const id =
726
- kind === "insert"
727
- ? createId(deps)
728
- : (props as unknown as { id: Id }).id;
729
-
730
- if (options?.onlyValidate !== true) {
731
- if (!result.ok) {
732
- // Mark the transaction as invalid by pushing null
733
- mutateMicrotaskQueue.push([null, undefined]);
734
- } else {
735
- const { id: _, isDeleted, ...values } = result.value;
736
-
737
- const dbChange = {
738
- table,
739
- id,
740
- values,
741
- isInsert: kind === "insert" || kind === "upsert",
742
- isDelete: SqliteBoolean.is(isDeleted)
743
- ? sqliteBooleanToBoolean(isDeleted)
744
- : null,
745
- };
838
+ if (isNonEmptySet(queries)) postMessage({ type: "Query", queries });
839
+ break;
840
+ }
746
841
 
842
+ case "OnExport": {
747
843
  assert(
748
- DbChange.is(dbChange),
749
- `Invalid DbChange for table '${table}': Please check schema type errors.`,
844
+ exportDatabasePending,
845
+ "OnExport received without pending export.",
750
846
  );
751
-
752
- mutateMicrotaskQueue.push([
753
- { ...dbChange, ownerId: options?.ownerId },
754
- options?.onComplete,
755
- ]);
847
+ exportDatabasePending.resolve(message.file);
848
+ exportDatabasePending = null;
849
+ break;
756
850
  }
757
851
 
758
- if (mutateMicrotaskQueue.length === 1) {
759
- queueMicrotask(processMutationQueue);
760
- }
852
+ default:
853
+ exhaustiveCheck(message);
761
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
+ }
762
891
 
763
- 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
+ };
764
914
 
765
- return err(
766
- result.error as
767
- | ValidMutationSizeError
768
- | InferErrors<
769
- ObjectType<MutationMapping<(typeof schema)[TableName], Kind>>
770
- >,
915
+ assert(
916
+ DbChange.is(dbChange),
917
+ `Invalid DbChange for table '${String(table)}'.`,
771
918
  );
919
+
920
+ mutateBatch.push({
921
+ change: { ...dbChange, ownerId: options?.ownerId ?? appOwner.id },
922
+ onComplete: options?.onComplete,
923
+ });
924
+
925
+ return { id };
772
926
  };
773
927
 
774
- const processMutationQueue = () => {
775
- const changes: Array<MutationChange> = [];
776
- const onCompletes = [];
928
+ const loadQuery = <R extends Row>(
929
+ query: Query<S, R>,
930
+ ): Promise<QueryRows<R>> => {
931
+ assertNotDisposed(moved);
777
932
 
778
- for (const [change, onComplete] of mutateMicrotaskQueue) {
779
- if (change !== null) changes.push(change);
780
- if (onComplete) onCompletes.push(onComplete);
933
+ const loadingPromise = loadingPromisesByQuery.get(query);
934
+ if (loadingPromise) {
935
+ return loadingPromise.promise as Promise<QueryRows<R>>;
781
936
  }
782
937
 
783
- const queueLength = mutateMicrotaskQueue.length;
784
- mutateMicrotaskQueue.length = 0;
938
+ const { promise, resolve } = Promise.withResolvers<QueryRows>();
939
+ const typedPromise = promise as LoadingPromise["promise"];
940
+ typedPromise.status = "pending";
785
941
 
786
- // Don't process any mutations if there was a validation error.
787
- // All mutations within a queue run as a single transaction.
788
- if (changes.length !== queueLength) {
789
- return;
790
- }
942
+ loadingPromisesByQuery.set(query, {
943
+ promise: typedPromise,
944
+ resolve,
945
+ releaseOnResolve: false,
946
+ });
947
+
948
+ queryBatch.push(query);
791
949
 
792
- const onCompleteIds = onCompletes.map(onCompleteCallbacks.register);
793
- loadingPromises.releaseUnsubscribedOnMutation();
950
+ return typedPromise as Promise<QueryRows<R>>;
951
+ };
794
952
 
795
- if (!isNonEmptyArray(changes)) return;
953
+ const getQueryRows = <R extends Row>(query: Query<S, R>): QueryRows<R> => {
954
+ assertNotDisposed(moved);
796
955
 
797
- dbWorker.postMessage({
798
- type: "mutate",
799
- tabId: getTabId(),
800
- changes,
801
- onCompleteIds,
802
- subscribedQueries: subscribedQueries.get(),
803
- });
956
+ return (rowsByQueryMapStore.get().get(query) ??
957
+ emptyArray) as QueryRows<R>;
804
958
  };
805
959
 
806
- const evolu: InternalEvoluInstance = {
807
- subscribeError: errorStore.subscribe,
808
- getError: errorStore.get,
809
-
810
- createQuery,
811
-
812
- loadQuery: <R extends Row>(query: Query<R>): Promise<QueryRows<R>> => {
813
- const { promise, isNew } = loadingPromises.get(query);
814
-
815
- if (isNew) {
816
- loadQueryMicrotaskQueue.push(query);
817
- if (loadQueryMicrotaskQueue.length === 1) {
818
- queueMicrotask(() => {
819
- const queries = dedupeArray(loadQueryMicrotaskQueue);
820
- loadQueryMicrotaskQueue.length = 0;
821
- assertNonEmptyReadonlyArray(queries);
822
- deps.console.log("[evolu]", "loadQuery", { queries });
823
- dbWorker.postMessage({
824
- type: "query",
825
- tabId: getTabId(),
826
- queries,
827
- });
828
- });
829
- }
830
- }
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
+ };
831
976
 
832
- return promise;
833
- },
977
+ useOwnerBatch.push({
978
+ owner: syncOwner,
979
+ action: "add",
980
+ });
834
981
 
835
- loadQueries: <R extends Row, Q extends Queries<R>>(
836
- queries: [...Q],
837
- ): [...QueriesToQueryRowsPromises<Q>] =>
838
- queries.map(evolu.loadQuery) as [...QueriesToQueryRowsPromises<Q>],
982
+ let isUsed = true;
839
983
 
840
- subscribeQuery: (query) => (listener) => {
841
- // Call the listener only if the result has been changed.
842
- let previousRows: unknown = null;
843
- const unsubscribe = subscribedQueries.subscribe(query)(() => {
844
- const rows = evolu.getQueryRows(query);
845
- if (previousRows === rows) return;
846
- previousRows = rows;
847
- listener();
984
+ return () => {
985
+ assert(isUsed, "UnuseOwner can be called only once.");
986
+ isUsed = false;
987
+ useOwnerBatch.push({
988
+ owner: syncOwner,
989
+ action: "remove",
848
990
  });
849
- return () => {
850
- previousRows = null;
851
- unsubscribe();
852
- };
853
- },
991
+ };
992
+ };
854
993
 
855
- getQueryRows: <R extends Row>(query: Query<R>): QueryRows<R> =>
856
- (rowsStore.get().get(query) ?? emptyRows) as QueryRows<R>,
994
+ const moved = stack.move();
857
995
 
858
- appOwner,
996
+ if (isNonEmptyArray(transports)) useOwner(appOwner);
859
997
 
860
- // TODO: Update it for the owner-api
861
- // subscribeSyncState: syncStore.subscribe,
862
- // getSyncState: syncStore.get,
998
+ return ok({
999
+ name,
1000
+ appOwner,
863
1001
 
864
1002
  insert: createMutation("insert"),
865
1003
  update: createMutation("update"),
866
1004
  upsert: createMutation("upsert"),
867
1005
 
868
- resetAppOwner: (options) => {
869
- const { promise, resolve } = Promise.withResolvers<undefined>();
870
- const onCompleteId = onCompleteCallbacks.register(resolve);
871
- dbWorker.postMessage({
872
- type: "reset",
873
- onCompleteId,
874
- reload: options?.reload ?? true,
875
- });
876
- return promise;
877
- },
878
-
879
- restoreAppOwner: (mnemonic, options) => {
880
- const { promise, resolve } = Promise.withResolvers<undefined>();
881
- const onCompleteId = onCompleteCallbacks.register(resolve);
882
- dbWorker.postMessage({
883
- type: "reset",
884
- onCompleteId,
885
- reload: options?.reload ?? true,
886
- restore: { mnemonic, dbSchema },
887
- });
888
- return promise;
889
- },
890
-
891
- reloadApp: () => {
892
- deps.reloadApp(reloadUrl);
893
- },
894
-
895
- ensureSchema: (schema) => {
896
- mutationTypesCache.clear();
897
- const dbSchema = evoluSchemaToDbSchema(schema);
898
- dbWorker.postMessage({ type: "ensureDbSchema", dbSchema });
899
- },
900
-
901
- exportDatabase: () => {
902
- const { promise, resolve } =
903
- Promise.withResolvers<Uint8Array<ArrayBuffer>>();
904
- const onCompleteId = exportCallbacks.register(resolve);
905
- dbWorker.postMessage({ type: "export", onCompleteId });
906
- return promise;
907
- },
908
-
909
- useOwner: (owner) => {
910
- const scheduleOwnerQueueProcessing = () => {
911
- if (useOwnerMicrotaskQueue.length !== 1) return;
912
- queueMicrotask(() => {
913
- const queue = [...useOwnerMicrotaskQueue];
914
- 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
+ ],
915
1013
 
916
- const result: Array<[SyncOwner, boolean, Uint8Array]> = [];
917
- const skipIndices = new Set<number>();
1014
+ subscribeQuery: (query) => (listener) => {
1015
+ assertNotDisposed(moved);
918
1016
 
919
- for (let i = 0; i < queue.length; i++) {
920
- if (skipIndices.has(i)) continue;
1017
+ subscribedQueriesRefCount.increment(query);
1018
+ let isSubscribed = true;
921
1019
 
922
- const [currentOwner, currentUse, currentOwnerSerialized] =
923
- queue[i];
1020
+ let previousRows: unknown = null;
924
1021
 
925
- // Look for opposite action with same owner
926
- for (let j = i + 1; j < queue.length; j++) {
927
- 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
+ });
928
1028
 
929
- const [, otherUse, otherOwnerSerialized] = queue[j];
1029
+ return () => {
1030
+ assert(
1031
+ isSubscribed,
1032
+ "subscribeQuery unsubscribe can be called only once.",
1033
+ );
1034
+ isSubscribed = false;
930
1035
 
931
- if (
932
- currentUse !== otherUse &&
933
- eqArrayNumber(currentOwnerSerialized, otherOwnerSerialized)
934
- ) {
935
- // Found cancel-out pair, skip both
936
- skipIndices.add(i).add(j);
937
- break;
938
- }
939
- }
1036
+ previousRows = null;
1037
+ unsubscribe();
940
1038
 
941
- if (!skipIndices.has(i)) {
942
- result.push([currentOwner, currentUse, currentOwnerSerialized]);
943
- }
944
- }
1039
+ if (moved.disposed) return;
945
1040
 
946
- for (const [owner, use] of result) {
947
- dbWorker.postMessage({ type: "useOwner", owner, use });
948
- }
949
- });
1041
+ subscribedQueriesRefCount.decrement(query);
950
1042
  };
1043
+ },
1044
+ getQueryRows,
951
1045
 
952
- useOwnerMicrotaskQueue.push([owner, true, pack(owner)]);
953
- scheduleOwnerQueueProcessing();
954
-
955
- const unuse = () => {
956
- useOwnerMicrotaskQueue.push([owner, false, pack(owner)]);
957
- scheduleOwnerQueueProcessing();
958
- };
1046
+ exportDatabase: () => {
1047
+ assertNotDisposed(moved);
959
1048
 
960
- return unuse;
1049
+ if (!exportDatabasePending) {
1050
+ exportDatabasePending =
1051
+ Promise.withResolvers<Uint8Array<ArrayBuffer>>();
1052
+ postMessage({ type: "Export" });
1053
+ }
1054
+ return exportDatabasePending.promise;
961
1055
  },
962
1056
 
963
- /** Disposal is not implemented yet. */
964
- [Symbol.dispose]: () => {
965
- throw new Error("Evolu instance disposal is not yet implemented");
1057
+ deleteDatabase: () => {
1058
+ assertNotDisposed(moved);
1059
+ todo();
966
1060
  },
967
- };
968
-
969
- return evolu;
970
- };
971
-
972
- export const createQuery = <R extends Row>(
973
- queryCallback: Parameters<CreateQuery<EvoluSchema>>[0],
974
- options?: Parameters<CreateQuery<EvoluSchema>>[1],
975
- ): Query<R> => {
976
- const compiledQuery = queryCallback(kysely as IntentionalNever).compile();
977
-
978
- if (isSqlMutation(compiledQuery.sql))
979
- throw new Error(
980
- "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.",
981
- );
982
-
983
- return serializeQuery({
984
- sql: compiledQuery.sql as SafeSql,
985
- parameters: compiledQuery.parameters as NonNullable<
986
- SqliteQuery["parameters"]
987
- >,
988
- ...(options && { options }),
989
- });
990
- };
991
1061
 
992
- interface LoadingPromises {
993
- get: <R extends Row>(
994
- query: Query<R>,
995
- ) => {
996
- readonly promise: Promise<QueryRows<R>>;
997
- readonly isNew: boolean;
998
- };
999
-
1000
- resolve: (query: Query, rows: ReadonlyArray<Row>) => void;
1001
-
1002
- releaseUnsubscribedOnMutation: () => void;
1062
+ deleteOwner: (owner) => {
1063
+ assertNotDisposed(moved);
1064
+ void owner;
1065
+ todo();
1066
+ },
1003
1067
 
1004
- getQueries: () => ReadonlyArray<Query>;
1005
- }
1068
+ useOwner,
1006
1069
 
1007
- interface LoadingPromise {
1008
- /** Promise with props for the React use hook. */
1009
- promise: Promise<QueryRows> & {
1010
- status?: "pending" | "fulfilled" | "rejected";
1011
- value?: QueryRows;
1012
- reason?: unknown;
1070
+ [Symbol.asyncDispose]: () => {
1071
+ console.info("dispose");
1072
+ return moved.disposeAsync();
1073
+ },
1074
+ } as Evolu<S>);
1013
1075
  };
1014
- resolve: (rows: QueryRows) => void;
1015
- releaseOnResolve: boolean;
1016
- }
1017
-
1018
- const createLoadingPromises = (
1019
- subscribedQueries: SubscribedQueries,
1020
- ): LoadingPromises => {
1021
- const loadingPromiseMap = new Map<Query, LoadingPromise>();
1022
-
1023
- return {
1024
- get: <R extends Row>(
1025
- query: Query<R>,
1026
- ): {
1027
- readonly promise: Promise<QueryRows<R>>;
1028
- readonly isNew: boolean;
1029
- } => {
1030
- let loadingPromise = loadingPromiseMap.get(query);
1031
- const isNew = !loadingPromise;
1032
- if (!loadingPromise) {
1033
- const { promise, resolve } = Promise.withResolvers<QueryRows>();
1034
- loadingPromise = { resolve, promise, releaseOnResolve: false };
1035
- loadingPromiseMap.set(query, loadingPromise);
1036
- }
1037
- return {
1038
- promise: loadingPromise.promise as Promise<QueryRows<R>>,
1039
- isNew,
1040
- };
1041
- },
1042
-
1043
- resolve: (query, rows) => {
1044
- const loadingPromise = loadingPromiseMap.get(query);
1045
- if (!loadingPromise) return;
1046
-
1047
- if (loadingPromise.promise.status !== "fulfilled") {
1048
- loadingPromise.resolve(rows);
1049
- } else {
1050
- loadingPromise.promise = Promise.resolve(rows);
1051
- }
1052
1076
 
1053
- // Set status and value fields for React's `use` Hook to unwrap synchronously.
1054
- // While undocumented in React docs, React still uses these properties internally,
1055
- // and Evolu's own promise caching logic depends on checking `promise.status`.
1056
- // https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md
1057
- void Object.assign(loadingPromise.promise, {
1058
- status: "fulfilled",
1059
- value: rows,
1060
- });
1061
-
1062
- if (loadingPromise.releaseOnResolve) {
1063
- loadingPromiseMap.delete(query);
1064
- }
1065
- },
1066
-
1067
- releaseUnsubscribedOnMutation: () => {
1068
- [...loadingPromiseMap.entries()]
1069
- .filter(([query]) => !subscribedQueries.has(query))
1070
- .forEach(([query, loadingPromise]) => {
1071
- if (loadingPromise.promise.status === "fulfilled") {
1072
- loadingPromiseMap.delete(query);
1073
- } else {
1074
- loadingPromise.releaseOnResolve = true;
1075
- }
1076
- });
1077
- },
1078
-
1079
- getQueries: () => Array.from(loadingPromiseMap.keys()),
1080
- };
1081
- };
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>;