@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2355 -81
  4. package/dist/index.js +13247 -2595
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +793 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +507 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +112 -0
  54. package/src/modules/auth/impersonation.test.ts +282 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +317 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +280 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +591 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.26",
3
+ "version": "0.0.1-canary.261",
4
4
  "type": "module",
5
+ "sideEffects": false,
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",
7
8
  "exports": {
@@ -17,12 +18,15 @@
17
18
  "scripts": {
18
19
  "dev": "tsdown --watch",
19
20
  "dev:example": "pnpm dev",
20
- "build": "tsdown",
21
- "test": "vitest"
21
+ "build": "tsdown && node scripts/check-broker-bundle.mjs",
22
+ "test": "vitest",
23
+ "test:run": "vitest run",
24
+ "test:coverage": "vitest run --coverage",
25
+ "type-check": "tsc --noEmit"
22
26
  },
23
27
  "repository": {
24
28
  "type": "git",
25
- "url": "https://github.com/mono424/spooky.git",
29
+ "url": "https://github.com/mono424/sp00ky.git",
26
30
  "directory": "packages/core"
27
31
  },
28
32
  "keywords": [
@@ -59,15 +63,19 @@
59
63
  }
60
64
  },
61
65
  "dependencies": {
62
- "@spooky-sync/query-builder": "workspace:*",
63
- "@spooky-sync/ssp-wasm": "workspace:*",
64
- "@surrealdb/wasm": "^3.0.0",
66
+ "@spooky-sync/query-builder": "0.0.1-canary.261",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.261",
68
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
+ "@surrealdb/wasm": "^3.0.3",
70
+ "blurhash": "^2.0.5",
65
71
  "fast-json-patch": "^3.1.1",
72
+ "loro-crdt": "^1.5.6",
66
73
  "pino": "^10.1.0",
67
- "surrealdb": "2.0.0"
74
+ "surrealdb": "2.0.3"
68
75
  },
69
76
  "devDependencies": {
70
77
  "@types/node": "^22.5.2",
78
+ "@vitest/coverage-v8": "^3.2.4",
71
79
  "tsdown": "^0.12.4",
72
80
  "typescript": "^5.6.2",
73
81
  "vitest": "^3.2.0"
@@ -0,0 +1,33 @@
1
+ // Build gate: dist/tabs-broker-worker.js must be self-contained ESM.
2
+ // Bundlers cannot trace `new SharedWorker(url)` module graphs, so any
3
+ // import statement surviving into the emitted broker file would 404 at
4
+ // runtime in consumers (the file is fetched as a bare URL, its relative
5
+ // chunk siblings are not copied along). Run after `tsdown` in the build.
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const dist = resolve(dirname(fileURLToPath(import.meta.url)), '../dist/tabs-broker-worker.js');
11
+
12
+ let code;
13
+ try {
14
+ code = readFileSync(dist, 'utf8');
15
+ } catch {
16
+ console.error(`check-broker-bundle: ${dist} missing; did tsdown run?`);
17
+ process.exit(1);
18
+ }
19
+
20
+ const problems = [];
21
+ // Static or dynamic imports of sibling chunks.
22
+ const importRe = /(?:^|\n)\s*import[\s(]/;
23
+ if (importRe.test(code)) problems.push('contains an import statement');
24
+ if (!code.includes('onconnect')) problems.push('missing onconnect handler');
25
+
26
+ if (problems.length > 0) {
27
+ console.error(`check-broker-bundle: dist/tabs-broker-worker.js ${problems.join('; ')}.`);
28
+ console.error(
29
+ 'The broker worker must stay free of runtime imports (type-only imports are fine in source).'
30
+ );
31
+ process.exit(1);
32
+ }
33
+ console.log('check-broker-bundle: ok');
@@ -1,27 +1,27 @@
1
1
  ---
2
- name: spooky-core
2
+ name: sp00ky-core
3
3
  description: >-
4
- Core client for the Spooky reactive local-first SurrealDB framework. Use when
5
- initializing SpookyClient, registering queries and subscriptions, performing
4
+ Core client for the Sp00ky reactive local-first SurrealDB framework. Use when
5
+ initializing Sp00kyClient, registering queries and subscriptions, performing
6
6
  mutations (create/update/delete), handling auth (signUp/signIn/signOut),
7
7
  working with file buckets, or using backend runs (HTTP outbox pattern).
8
8
  metadata:
9
- author: spooky-sync
9
+ author: sp00ky-sync
10
10
  version: "0.0.1"
11
11
  ---
12
12
 
13
- # Spooky Core
13
+ # Sp00ky Core
14
14
 
15
- `@spooky-sync/core` provides `SpookyClient`, the main entry point for the Spooky framework. It handles local-first data sync with SurrealDB, reactive queries, optimistic mutations, authentication, file storage, and backend runs.
15
+ `@spooky-sync/core` provides `Sp00kyClient`, the main entry point for the Sp00ky framework. It handles local-first data sync with SurrealDB, reactive queries, optimistic mutations, authentication, file storage, and backend runs.
16
16
 
17
17
  ## Initialization
18
18
 
19
19
  ```typescript
20
- import { SpookyClient } from '@spooky-sync/core';
20
+ import { Sp00kyClient } from '@spooky-sync/core';
21
21
  import { schema } from './generated/schema'; // Generated by `spooky generate`
22
22
  import schemaSurql from './generated/schema.surql?raw';
23
23
 
24
- const client = new SpookyClient({
24
+ const client = new Sp00kyClient({
25
25
  database: {
26
26
  endpoint: 'ws://localhost:8000',
27
27
  namespace: 'my_ns',
@@ -40,7 +40,7 @@ await client.init();
40
40
  await client.close();
41
41
  ```
42
42
 
43
- ### SpookyConfig Options
43
+ ### Sp00kyConfig Options
44
44
 
45
45
  See [references/config.md](references/config.md) for all configuration options.
46
46
 
@@ -82,6 +82,15 @@ unsubscribe();
82
82
 
83
83
  Queries have a time-to-live. Supported values: `'1m'`, `'5m'`, `'10m'` (default), `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`–`'12h'`, `'1d'`.
84
84
 
85
+ ### Preloading
86
+
87
+ `db.preload(query, { signal? })` registers a query without subscribing to it. If
88
+ the server answered this query on this device before (a durable `_00_view` row
89
+ exists) it resolves at once and the rows paint from the local store; otherwise
90
+ it resolves once the server's membership and every record are local. Nothing
91
+ to tear down: the entry is evicted a ttl after registration unless a view mounts
92
+ the same query. `refresh` / `staleTime` are accepted but ignored.
93
+
85
94
  ## Mutations
86
95
 
87
96
  Mutations are optimistic and automatically synced to the remote SurrealDB instance.
@@ -112,6 +121,15 @@ Use `debounced` option for frequent updates (e.g., live typing):
112
121
  - `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
113
122
  - `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
114
123
 
124
+ ### Failed writes
125
+
126
+ A push the server rejects (permission, validation) is rolled back locally
127
+ (create, update and delete) and moved to the `_00_failed_mutations` tray in the
128
+ same local transaction. Network errors never do this; they retry. Inspect and
129
+ act on the tray with `db.failedMutationCount`, `db.subscribeToFailedMutations(cb)`,
130
+ `db.listFailedMutations()`, `db.retryFailedMutation(id)` (a fresh optimistic
131
+ write) and `db.discardFailedMutation(id)`.
132
+
115
133
  ## Authentication
116
134
 
117
135
  See [references/auth.md](references/auth.md) for the full auth API.
@@ -159,13 +177,13 @@ Buckets must be defined in the schema under the `buckets` array.
159
177
 
160
178
  ## Backend Runs (HTTP Outbox)
161
179
 
162
- Backend runs let you trigger server-side HTTP operations via an **outbox pattern**. When you call `db.run()`, it creates a job record in a local outbox table. That record syncs to remote SurrealDB, where the Spooky Sync Platform (SSP) picks it up and calls your backend HTTP API. The result status is written back to the job record and syncs to the client reactively.
180
+ Backend runs let you trigger server-side HTTP operations via an **outbox pattern**. When you call `db.run()`, it creates a job record in a local outbox table. That record syncs to remote SurrealDB, where the Sp00ky Sync Platform (SSP) picks it up and calls your backend HTTP API. The result status is written back to the job record and syncs to the client reactively.
163
181
 
164
182
  ### `db.run()` Signature
165
183
 
166
184
  ```typescript
167
185
  db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
168
- backend: B, // Backend name from spooky.yml (e.g., 'api')
186
+ backend: B, // Backend name from sp00ky.yml (e.g., 'api')
169
187
  route: R, // Route path from OpenAPI spec (e.g., '/spookify')
170
188
  payload: RoutePayload<S, B, R>, // Typed args for the route
171
189
  options?: RunOptions
@@ -192,7 +210,7 @@ The `assignedTo` option is how you link a job to an entity. Passing a record ID:
192
210
 
193
211
  ### Full Example
194
212
 
195
- **1. Configure the backend in `spooky.yml`:**
213
+ **1. Configure the backend in `sp00ky.yml`:**
196
214
 
197
215
  ```yaml
198
216
  backends:
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## AuthService API
4
4
 
5
- The `AuthService` is available on `SpookyClient` as `client.auth`.
5
+ The `AuthService` is available on `Sp00kyClient` as `client.auth`.
6
6
 
7
7
  ### Properties
8
8
 
@@ -1,7 +1,7 @@
1
- # SpookyConfig Reference
1
+ # Sp00kyConfig Reference
2
2
 
3
3
  ```typescript
4
- interface SpookyConfig<S extends SchemaStructure> {
4
+ interface Sp00kyConfig<S extends SchemaStructure> {
5
5
  database: {
6
6
  /** SurrealDB WebSocket endpoint URL */
7
7
  endpoint?: string;
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { fakeServices } from '../testing/services';
5
+ import { buildEntry, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { authFlip, persistVerifiedUser } from './auth-flip.saga';
9
+
10
+ const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
11
+
12
+ describe('authFlip', () => {
13
+ it('sets identity and $auth before the bucket work, writes the hint, switches, rotates the salt for a new principal, persists the user row', async () => {
14
+ const released: string[] = [];
15
+ const svc = fakeServices({
16
+ 'auth.sessionAuthId': () => 'user:abc',
17
+ 'auth.access': () => 'account',
18
+ 'local.currentBucketId': () => 'anon',
19
+ 'local.beginSwitch': () => () => void released.push('gate'),
20
+ 'local.usesSurqlSchema': () => false,
21
+ 'auth.token': () => 'jwt',
22
+ 'auth.currentUser': () => ({ id: new RecordId('user', 'abc'), email_verified: true }),
23
+ });
24
+ const state = { ...buildState(), sessionId: 'old', saltUserId: null };
25
+ const out = await runPure(authFlip(env, 'user:abc'), {
26
+ state,
27
+ handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
28
+ });
29
+ const names = svc.names();
30
+ expect(names.slice(0, 5)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'hint.write', 'local.currentBucketId']);
31
+ expect(svc.calls[3]).toEqual(['hint.write', ['abc']]);
32
+ expect(names).toContain('local.switchStore');
33
+ expect(names).toContain('crdt.setSessionId');
34
+ expect(released).toEqual(['gate']);
35
+ expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
36
+ expect(out.state.versions.get('user:abc')).toBe(1);
37
+ });
38
+ it('same principal on the same bucket: no switch, no salt rotation', async () => {
39
+ const svc = fakeServices({
40
+ 'auth.sessionAuthId': () => 'user:abc',
41
+ 'auth.access': () => 'account',
42
+ 'local.currentBucketId': () => 'abc',
43
+ 'auth.currentUser': () => null,
44
+ });
45
+ const state = { ...buildState(), sessionId: 'keep', saltUserId: 'user:abc', bucketId: 'abc' };
46
+ const out = await runPure(authFlip(env, 'user:abc'), { state, handlers: { service: svc.handler } });
47
+ expect(svc.names()).not.toContain('local.switchStore');
48
+ expect(svc.names()).not.toContain('local.beginSwitch');
49
+ expect(out.state.sessionId).toBe('keep');
50
+ });
51
+ });
52
+
53
+ describe('persistVerifiedUser', () => {
54
+ it('skips when there is no verified row, an unknown table, or a bare id; writes otherwise and logs failures', async () => {
55
+ for (const row of [null, { id: 'user:x', a: 1 }, { id: new RecordId('user', 'x') }, { id: new RecordId('nope', 'x'), a: 1 }]) {
56
+ const svc = fakeServices({ 'auth.currentUser': () => row as any });
57
+ const out = await runPure(persistVerifiedUser(env), { state: buildState(), handlers: { service: svc.handler } });
58
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
59
+ }
60
+ const svc = fakeServices({ 'auth.currentUser': () => ({ id: new RecordId('user', 'x'), email_verified: true, junk: 1 }) });
61
+ const s = buildState([buildEntry({ def: { hash: 'q', tableName: 'user' } })], R.setVersions([['user:x', 4]]));
62
+ const out = await runPure(persistVerifiedUser(env), {
63
+ state: s,
64
+ handlers: {
65
+ service: svc.handler,
66
+ 'local.execute': (e: any) => expect(e.vars.content0).toEqual({ email_verified: true, _00_rv: 4 }),
67
+ 'ssp.ingest': (e: any) => expect(e.records[0].record.junk).toBeUndefined(),
68
+ },
69
+ });
70
+ expect(out.state.dirty.has('q')).toBe(true);
71
+ const failing = await runPure(persistVerifiedUser(env), {
72
+ state: s,
73
+ handlers: {
74
+ service: svc.handler,
75
+ 'local.execute': () => {
76
+ throw new Error('locked');
77
+ },
78
+ },
79
+ });
80
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
81
+ });
82
+ });
@@ -0,0 +1,62 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { Saga } from '../kernel/saga';
3
+ import { fx } from '../kernel/effects';
4
+ import * as R from '../state/reducers';
5
+ import { bucketIdForUser } from '../modules/ref-tables';
6
+ import type { SagaEnv } from '../query/env';
7
+ import { columnsFor } from '../query/env';
8
+ import * as sql from '../query/sql';
9
+ import { encodeRecordId } from '../utils/index';
10
+ import { cleanRecord } from '../utils/parser';
11
+ import { bucketSwitch } from './bucket-switch.saga';
12
+
13
+ /**
14
+ * The signed-in principal changed (sign-in, sign-out, boot verification).
15
+ * Identity first (routing and `$auth` for the local SSP), then the bucket,
16
+ * then the salt if the principal really changed, then the verified user row.
17
+ */
18
+ export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
19
+ const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
20
+ const access = (yield fx.service('auth.access')) as string | null;
21
+ yield fx.state.update(R.setIdentity({ userId }));
22
+ yield fx.service('ssp.setSessionAuth', authId, access);
23
+ const target = bucketIdForUser(userId);
24
+ yield fx.service('hint.write', target);
25
+ yield fx.state.update(R.setIdentity({ pendingBucket: target }));
26
+ const current = (yield fx.service('local.currentBucketId')) as string;
27
+ const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
28
+ yield* bucketSwitch(env, target, release);
29
+ const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
30
+ if (authId !== saltUserId) {
31
+ const salt = (yield fx.id('salt')) as string;
32
+ yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
33
+ yield fx.service('crdt.setSessionId', salt);
34
+ }
35
+ yield* persistVerifiedUser(env);
36
+ }
37
+
38
+ /**
39
+ * Write the user's own row, as the server just returned it, into the local
40
+ * store so `user` queries see `email_verified` and friends without waiting
41
+ * for membership. A field refresh, not a version claim.
42
+ */
43
+ export function* persistVerifiedUser(env: SagaEnv): Saga<void> {
44
+ const row = (yield fx.service('auth.currentUser')) as Record<string, unknown> | null;
45
+ if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
46
+ const rid = row.id as RecordId<string>;
47
+ const table = String(rid.table);
48
+ const columns = columnsFor(env, table);
49
+ if (!columns) return;
50
+ const id = encodeRecordId(rid);
51
+ const version = ((yield fx.state.read((s) => s.versions.get(id))) as number | undefined) || 1;
52
+ const cleaned = cleanRecord(columns as never, row);
53
+ const { id: _id, ...content } = cleaned;
54
+ try {
55
+ const tx = sql.upsertBodiesTx([{ id: rid, content: { ...content, _00_rv: version } }]);
56
+ yield fx.local.execute(tx.query, tx.vars);
57
+ yield fx.ssp.ingest([{ table, op: 'UPDATE', id, record: { ...cleaned, _00_rv: version } }]);
58
+ yield fx.state.update(R.compose(R.setVersions([[id, version]]), R.markTableDirty(table)));
59
+ } catch (error) {
60
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not persist the verified user row', data: { error } });
61
+ }
62
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { fakeServices } from '../testing/services';
5
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
6
+ import * as R from '../state/reducers';
7
+ import { defaultEnv } from '../query/env';
8
+ import { boot, migrateWindowToView, pageHide, primeCircuit, startRemote, versionsPrimed, warmBlobs } from './boot.saga';
9
+
10
+ const env = defaultEnv({ tables: [] } as any);
11
+
12
+ describe('boot', () => {
13
+ it('solo: opens the hinted bucket, provisions, starts the SSP, restores the session, mirrors the outbox, flips localReady, hands off to the network', async () => {
14
+ const svc = fakeServices({
15
+ 'hint.read': () => 'u1',
16
+ 'local.usesSurqlSchema': () => true,
17
+ 'auth.restoreSession': async () => 'user:u1',
18
+ 'auth.sessionAuthId': () => 'user:u1',
19
+ 'auth.access': () => 'account',
20
+ });
21
+ const out = await runPure(boot(env, { sharedTabs: false }), {
22
+ handlers: { service: svc.handler, 'local.query': (e: any) => (e.sql.startsWith('SELECT count()') ? [[{ count: 1 }]] : []) },
23
+ });
24
+ expect(svc.names()).toEqual([
25
+ 'hint.read',
26
+ 'local.connect',
27
+ 'local.usesSurqlSchema',
28
+ 'migrator.provision',
29
+ 'ssp.init',
30
+ 'ssp.setPermissions',
31
+ 'auth.restoreSession',
32
+ 'auth.sessionAuthId',
33
+ 'crdt.setSessionId',
34
+ 'auth.access',
35
+ 'ssp.setSessionAuth',
36
+ 'window.attach',
37
+ 'features.init',
38
+ 'releases.init',
39
+ ]);
40
+ expect(svc.calls[1]).toEqual(['local.connect', ['u1']]);
41
+ expect(out.state).toMatchObject({ bucketId: 'u1', userId: 'user:u1', saltUserId: 'user:u1', sessionId: 'salt-1', localReady: true });
42
+ expect(out.dispatched.map((d) => d.type)).toEqual(['WarmBlobs', 'PrimeCircuit', 'LifecycleTick', 'GcTick', 'StartRemote']);
43
+ expect(svc.names()).not.toContain('remote.connect');
44
+ });
45
+ it('shared tabs: takes the role from the coordinator, or falls back to solo; schemaless engines skip provisioning; anonymous skips session auth', async () => {
46
+ const leader = fakeServices({ 'hint.read': () => null, 'tabs.start': async () => 'leader', 'local.usesSurqlSchema': () => false, 'auth.restoreSession': async () => null, 'auth.sessionAuthId': () => null });
47
+ const out = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: leader.handler, 'local.query': () => [] } });
48
+ expect(out.state.tabRole).toBe('leader');
49
+ expect(out.state.bucketId).toBe('anon');
50
+ expect(leader.names()).not.toContain('local.connect');
51
+ expect(leader.names()).not.toContain('migrator.provision');
52
+ expect(leader.names()).not.toContain('ssp.setSessionAuth');
53
+ const fallback = fakeServices({
54
+ 'hint.read': () => 'x',
55
+ 'tabs.start': async () => {
56
+ throw new Error('no broker');
57
+ },
58
+ 'local.usesSurqlSchema': () => false,
59
+ 'auth.restoreSession': async () => null,
60
+ 'auth.sessionAuthId': () => null,
61
+ });
62
+ const fb = await runPure(boot(env, { sharedTabs: true }), { handlers: { service: fallback.handler, 'local.query': () => [] } });
63
+ expect(fallback.calls).toContainEqual(['local.connect', ['x']]);
64
+ expect(fb.emitted).toContainEqual(expect.objectContaining({ level: 'warn' }));
65
+ });
66
+ });
67
+
68
+ describe('migrateWindowToView', () => {
69
+ it('copies legacy rows once, skips when _00_view has rows, tolerates errors and junk', async () => {
70
+ const upserts: any[] = [];
71
+ await runPure(migrateWindowToView(), {
72
+ handlers: {
73
+ 'local.query': (e: any) =>
74
+ e.sql.startsWith('SELECT count()')
75
+ ? [[]]
76
+ : [[{ id: '_00_window:k1', ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, { id: new RecordId('_00_window', 'k2'), ids: [] }, { id: 'x', ids: 'bad' }, { ids: [] }]],
77
+ 'local.upsert': (e: any) => void upserts.push(e),
78
+ },
79
+ });
80
+ expect(upserts).toEqual([
81
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k1'), data: { ids: [['t:1', 1]], confirmed: true, updatedAt: 5 }, mode: 'replace' },
82
+ { kind: 'local.upsert', table: '_00_view', id: new RecordId('_00_view', 'k2'), data: { ids: [], confirmed: false, updatedAt: 0 }, mode: 'replace' },
83
+ ]);
84
+ const skipped = await runPure(migrateWindowToView(), { handlers: { 'local.query': () => [[{ count: 3 }]] } });
85
+ expect(skipped.log.filter((e) => e.kind === 'local.query')).toHaveLength(1);
86
+ const failing = await runPure(migrateWindowToView(), {
87
+ handlers: {
88
+ 'local.query': () => {
89
+ throw new Error('no table');
90
+ },
91
+ },
92
+ });
93
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'debug' })]);
94
+ });
95
+ });
96
+
97
+ describe('startRemote / primeCircuit / versionsPrimed / pageHide / warmBlobs', () => {
98
+ it('startRemote tolerates connect and auth failures and starts the loops', async () => {
99
+ const svc = fakeServices({
100
+ 'remote.connect': async () => {
101
+ throw new Error('offline');
102
+ },
103
+ 'auth.init': async () => {
104
+ throw new Error('401');
105
+ },
106
+ });
107
+ const out = await runPure(startRemote(), { handlers: { service: svc.handler } });
108
+ expect(svc.names()).toEqual(['remote.connect', 'supervisor.start', 'auth.init']);
109
+ expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(2);
110
+ expect(out.dispatched.map((d) => d.type)).toEqual(['EnsureRegistered', 'LiveStart', 'PollTick', 'Drain']);
111
+ const ok = await runPure(startRemote(), { handlers: { service: fakeServices().handler } });
112
+ expect(ok.emitted).toEqual([]);
113
+ });
114
+ it('primeCircuit passes outbox ids and always flips primed', async () => {
115
+ const svc = fakeServices({ 'ssp.prime': async () => undefined });
116
+ const s = buildState([], R.outboxReplace([buildOutboxItem({ recordId: 'thing:1' })]));
117
+ const out = await runPure(primeCircuit(), { state: s, handlers: { service: svc.handler } });
118
+ expect(svc.calls).toEqual([['ssp.prime', [['thing:1']]]]);
119
+ expect(out.state.primed).toBe(true);
120
+ const failing = fakeServices({
121
+ 'ssp.prime': async () => {
122
+ throw new Error('snapshot');
123
+ },
124
+ });
125
+ const f = await runPure(primeCircuit(), { state: buildState(), handlers: { service: failing.handler } });
126
+ expect(f.state.primed).toBe(true);
127
+ expect(f.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
128
+ });
129
+ it('versionsPrimed records versions; pageHide releases registered views; warmBlobs is best-effort', async () => {
130
+ const v = await runPure(versionsPrimed([['thing:1', 3]]), { state: buildState() });
131
+ expect(v.state.versions.get('thing:1')).toBe(3);
132
+ const svc = fakeServices();
133
+ const s = buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } }), buildEntry({ def: { hash: 'b' } })]);
134
+ await runPure(pageHide(), { state: s, handlers: { service: svc.handler } });
135
+ expect(svc.calls).toEqual([['remote.releaseViews', [[new RecordId('_00_query', 'a')]]]]);
136
+ const none = fakeServices();
137
+ await runPure(pageHide(), { state: buildState(), handlers: { service: none.handler } });
138
+ expect(none.calls).toEqual([]);
139
+ const blobs = fakeServices({
140
+ 'blobs.start': async () => {
141
+ throw new Error('opfs');
142
+ },
143
+ });
144
+ const w = await runPure(warmBlobs(), { state: { ...buildState(), bucketId: 'u9' }, handlers: { service: blobs.handler } });
145
+ expect(blobs.calls).toEqual([['blobs.start', ['u9']]]);
146
+ expect(w.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
147
+ const anon = fakeServices({ 'blobs.start': async () => undefined });
148
+ await runPure(warmBlobs(), { state: buildState(), handlers: { service: anon.handler } });
149
+ expect(anon.calls).toEqual([['blobs.start', ['anon']]]);
150
+ });
151
+ });
@@ -0,0 +1,128 @@
1
+ import type { Saga } from '../kernel/saga';
2
+ import { fx } from '../kernel/effects';
3
+ import type { ClientState } from '../state/client-state';
4
+ import * as R from '../state/reducers';
5
+ import { ANON_USER_ID } from '../modules/ref-tables';
6
+ import type { SagaEnv } from '../query/env';
7
+ import * as sql from '../query/sql';
8
+ import { loadOutbox } from '../mutation/push.saga';
9
+
10
+ export interface BootOptions {
11
+ /** A shared-tabs coordinator is available: ask it for a role instead of opening the store alone. */
12
+ sharedTabs: boolean;
13
+ }
14
+
15
+ /**
16
+ * Local boot. Everything awaited here is network-free: the store opens,
17
+ * the schema provisions, the SSP starts, the session is restored from the
18
+ * cached token, the outbox is mirrored. `localReady` flips at the end and
19
+ * the network half runs in the background (`startRemote`).
20
+ */
21
+ export function* boot(env: SagaEnv, opts: BootOptions): Saga<void> {
22
+ const bucket = ((yield fx.service('hint.read')) as string | null) ?? ANON_USER_ID;
23
+ let shared = false;
24
+ if (opts.sharedTabs) {
25
+ try {
26
+ const role = (yield fx.service('tabs.start', bucket)) as ClientState['tabRole'];
27
+ yield fx.state.update(R.setTabRole(role));
28
+ shared = true;
29
+ } catch (error) {
30
+ yield fx.emit({ type: 'log', level: 'warn', message: 'shared tabs unavailable; booting solo', data: { error } });
31
+ }
32
+ }
33
+ if (!shared) yield fx.service('local.connect', bucket);
34
+ yield fx.state.update(R.setIdentity({ bucketId: bucket }));
35
+ if ((yield fx.service('local.usesSurqlSchema')) as boolean) yield fx.service('migrator.provision');
36
+ yield* migrateWindowToView();
37
+ yield fx.dispatch({ type: 'WarmBlobs' });
38
+ yield fx.service('ssp.init');
39
+ yield fx.service('ssp.setPermissions');
40
+ yield fx.dispatch({ type: 'PrimeCircuit' });
41
+
42
+ const restoredUserId = (yield fx.service('auth.restoreSession')) as string | null;
43
+ const salt = (yield fx.id('salt')) as string;
44
+ const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
45
+ yield fx.state.update(R.setIdentity({ sessionId: salt, userId: restoredUserId, saltUserId: authId }));
46
+ yield fx.service('crdt.setSessionId', salt);
47
+ if (restoredUserId) yield fx.service('ssp.setSessionAuth', authId, (yield fx.service('auth.access')) as string | null);
48
+
49
+ yield* loadOutbox(env);
50
+ yield fx.service('window.attach');
51
+ yield fx.service('features.init');
52
+ yield fx.service('releases.init');
53
+ yield fx.dispatch({ type: 'LifecycleTick' });
54
+ yield fx.dispatch({ type: 'GcTick' });
55
+ yield fx.state.update(R.setIdentity({ localReady: true }));
56
+ yield fx.dispatch({ type: 'StartRemote' });
57
+ }
58
+
59
+ /** One-time copy of the legacy `_00_window` rows into `_00_view`. */
60
+ export function* migrateWindowToView(): Saga<void> {
61
+ try {
62
+ const countRes = (yield fx.local.query(sql.countViewRows())) as unknown[];
63
+ const countRow = Array.isArray(countRes?.[0]) ? (countRes[0] as Array<{ count?: number }>)[0] : undefined;
64
+ if ((countRow?.count ?? 0) > 0) return;
65
+ const legacy = (yield fx.local.query(sql.readLegacyViewRows())) as unknown[];
66
+ const rows = Array.isArray(legacy?.[0]) ? (legacy[0] as Array<Record<string, unknown>>) : [];
67
+ for (const row of rows) {
68
+ const key = typeof row.id === 'string' ? row.id.slice(row.id.indexOf(':') + 1) : String((row.id as { id?: unknown })?.id ?? '');
69
+ if (!key || !Array.isArray(row.ids)) continue;
70
+ yield fx.local.upsert(sql.VIEW_TABLE, sql.viewRecordId(key), { ids: row.ids, confirmed: row.confirmed === true, updatedAt: row.updatedAt ?? 0 }, 'replace');
71
+ }
72
+ } catch (error) {
73
+ yield fx.emit({ type: 'log', level: 'debug', message: 'window -> view migration skipped', data: { error } });
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The network half of boot: connect, verify the restored session, then let
79
+ * membership, LIVE, the poll and the outbox start. Every step is best-effort.
80
+ */
81
+ export function* startRemote(): Saga<void> {
82
+ try {
83
+ yield fx.service('remote.connect');
84
+ } catch (error) {
85
+ yield fx.emit({ type: 'log', level: 'warn', message: 'remote connect failed; running from the local store', data: { error } });
86
+ }
87
+ yield fx.service('supervisor.start');
88
+ try {
89
+ yield fx.service('auth.init');
90
+ } catch (error) {
91
+ yield fx.emit({ type: 'log', level: 'warn', message: 'auth verification failed; keeping the restored session', data: { error } });
92
+ }
93
+ yield fx.dispatch({ type: 'EnsureRegistered' });
94
+ yield fx.dispatch({ type: 'LiveStart' });
95
+ yield fx.dispatch({ type: 'PollTick' });
96
+ yield fx.dispatch({ type: 'Drain' });
97
+ }
98
+
99
+ /** Fill the circuit from the local store; `primed` flips even on failure so fetches never hang. */
100
+ export function* primeCircuit(): Saga<void> {
101
+ const pending = (yield fx.state.read((s) => s.outbox.map((i) => i.recordId))) as string[];
102
+ try {
103
+ yield fx.service('ssp.prime', pending);
104
+ } catch (error) {
105
+ yield fx.emit({ type: 'log', level: 'warn', message: 'circuit prime failed; starting empty', data: { error } });
106
+ } finally {
107
+ yield fx.state.update(R.setIdentity({ primed: true }));
108
+ }
109
+ }
110
+
111
+ export function* versionsPrimed(entries: Array<readonly [string, number]>): Saga<void> {
112
+ yield fx.state.update(R.setVersions(entries));
113
+ }
114
+
115
+ /** Hand this tab's views back to the server when the page goes away for good. */
116
+ export function* pageHide(): Saga<void> {
117
+ const ids = (yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === 'registered').map((e) => e.def.id))) as unknown[];
118
+ if (ids.length > 0) yield fx.service('remote.releaseViews', ids);
119
+ }
120
+
121
+ export function* warmBlobs(): Saga<void> {
122
+ const bucket = ((yield fx.state.read((s) => s.bucketId)) as string | null) ?? ANON_USER_ID;
123
+ try {
124
+ yield fx.service('blobs.start', bucket);
125
+ } catch (error) {
126
+ yield fx.emit({ type: 'log', level: 'warn', message: 'blob cache failed to start', data: { error } });
127
+ }
128
+ }