@spooky-sync/core 0.0.0-canary.1 → 0.0.1-alpha.1

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 (228) hide show
  1. package/AGENTS.md +74 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2178 -411
  4. package/dist/index.js +12503 -2475
  5. package/dist/otel/index.d.ts +21 -0
  6. package/dist/otel/index.js +86 -0
  7. package/dist/sqlite-open.js +322 -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 +1060 -0
  13. package/package.json +49 -12
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/sp00ky-core/SKILL.md +276 -0
  16. package/skills/sp00ky-core/references/auth.md +98 -0
  17. package/skills/sp00ky-core/references/config.md +76 -0
  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 +158 -0
  23. package/src/boot/bucket-switch.saga.ts +115 -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 +94 -0
  32. package/src/client/runtime.test.ts +178 -0
  33. package/src/client/runtime.ts +285 -0
  34. package/src/client/services.ts +349 -0
  35. package/src/client/sp00ky-client.test.ts +241 -0
  36. package/src/client/sp00ky-client.ts +482 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -1
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +54 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +159 -0
  44. package/src/kernel/events.ts +55 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +143 -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 +2 -1
  53. package/src/modules/auth/index.ts +138 -25
  54. package/src/modules/crdt/crdt-field.ts +294 -0
  55. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  56. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  57. package/src/modules/crdt/index.ts +463 -0
  58. package/src/modules/crdt/loro-loader.ts +25 -0
  59. package/src/modules/devtools/flags.ts +349 -0
  60. package/src/modules/devtools/index.ts +459 -47
  61. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  62. package/src/modules/devtools/state-shape.test.ts +149 -0
  63. package/src/modules/devtools/storage-info.test.ts +79 -0
  64. package/src/modules/devtools/storage-info.ts +168 -0
  65. package/src/modules/devtools/versions.test.ts +74 -0
  66. package/src/modules/devtools/versions.ts +110 -0
  67. package/src/modules/feature-flag/index.test.ts +251 -0
  68. package/src/modules/feature-flag/index.ts +316 -0
  69. package/src/modules/ref-tables.test.ts +91 -0
  70. package/src/modules/ref-tables.ts +88 -0
  71. package/src/mutation/jobs.test.ts +25 -0
  72. package/src/mutation/jobs.ts +41 -0
  73. package/src/mutation/mutation-id.test.ts +37 -0
  74. package/src/mutation/mutation-id.ts +35 -0
  75. package/src/mutation/push.saga.test.ts +285 -0
  76. package/src/mutation/push.saga.ts +215 -0
  77. package/src/mutation/rows.test.ts +201 -0
  78. package/src/mutation/rows.ts +377 -0
  79. package/src/mutation/tray.saga.test.ts +63 -0
  80. package/src/mutation/tray.saga.ts +48 -0
  81. package/src/mutation/write.saga.test.ts +163 -0
  82. package/src/mutation/write.saga.ts +166 -0
  83. package/src/otel/index.ts +127 -0
  84. package/src/query/env.test.ts +23 -0
  85. package/src/query/env.ts +56 -0
  86. package/src/query/fetch.saga.test.ts +156 -0
  87. package/src/query/fetch.saga.ts +109 -0
  88. package/src/query/hash.test.ts +14 -0
  89. package/src/query/hash.ts +18 -0
  90. package/src/query/id-set-plan.test.ts +132 -0
  91. package/src/query/lifecycle.saga.test.ts +122 -0
  92. package/src/query/lifecycle.saga.ts +101 -0
  93. package/src/query/materialize.saga.test.ts +82 -0
  94. package/src/query/materialize.saga.ts +75 -0
  95. package/src/query/materialize.test.ts +50 -0
  96. package/src/query/materialize.ts +44 -0
  97. package/src/query/membership.saga.test.ts +246 -0
  98. package/src/query/membership.saga.ts +208 -0
  99. package/src/query/membership.test.ts +118 -0
  100. package/src/query/membership.ts +158 -0
  101. package/src/query/register.saga.test.ts +251 -0
  102. package/src/query/register.saga.ts +190 -0
  103. package/src/query/render-set.test.ts +36 -0
  104. package/src/query/render-set.ts +60 -0
  105. package/src/query/sql.test.ts +62 -0
  106. package/src/query/sql.ts +79 -0
  107. package/src/query/window-query.test.ts +70 -0
  108. package/src/query/window-query.ts +195 -0
  109. package/src/services/blobs/blob-cache.test.ts +359 -0
  110. package/src/services/blobs/blob-cache.ts +603 -0
  111. package/src/services/blobs/blob-manifest.ts +227 -0
  112. package/src/services/blobs/blob-store.test.ts +77 -0
  113. package/src/services/blobs/blob-store.ts +359 -0
  114. package/src/services/blobs/blob.fixture.ts +90 -0
  115. package/src/services/blobs/index.ts +70 -0
  116. package/src/services/database/cache-engine.ts +193 -0
  117. package/src/services/database/connection-supervisor.test.ts +289 -0
  118. package/src/services/database/connection-supervisor.ts +415 -0
  119. package/src/services/database/database.query-timeout.test.ts +140 -0
  120. package/src/services/database/database.responses.test.ts +43 -0
  121. package/src/services/database/database.ts +145 -62
  122. package/src/services/database/engine-factory.ts +33 -0
  123. package/src/services/database/errors.ts +34 -0
  124. package/src/services/database/events/index.ts +2 -1
  125. package/src/services/database/index.ts +7 -0
  126. package/src/services/database/local-migrator.ts +30 -27
  127. package/src/services/database/local.test.ts +64 -0
  128. package/src/services/database/local.ts +484 -67
  129. package/src/services/database/plan-render.test.ts +159 -0
  130. package/src/services/database/plan-render.ts +108 -0
  131. package/src/services/database/relation-resolver.test.ts +467 -0
  132. package/src/services/database/relation-resolver.ts +0 -0
  133. package/src/services/database/remote.ts +222 -15
  134. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  135. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  136. package/src/services/database/sqlite-cache-engine.ts +1349 -0
  137. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  138. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  139. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  140. package/src/services/database/sqlite-lock-verify.ts +45 -0
  141. package/src/services/database/sqlite-open.test.ts +150 -0
  142. package/src/services/database/sqlite-open.ts +164 -0
  143. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  144. package/src/services/database/sqlite-plan-sql.ts +138 -0
  145. package/src/services/database/sqlite-projection.test.ts +99 -0
  146. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  147. package/src/services/database/sqlite-select.test.ts +246 -0
  148. package/src/services/database/sqlite-select.ts +131 -0
  149. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  150. package/src/services/database/sqlite-transport.ts +224 -0
  151. package/src/services/database/sqlite-worker.ts +437 -0
  152. package/src/services/database/surql-translate.ts +416 -0
  153. package/src/services/database/surreal-cache-engine.ts +161 -0
  154. package/src/services/logger/index.ts +6 -101
  155. package/src/services/persistence/localstorage.ts +2 -2
  156. package/src/services/persistence/resilient.ts +41 -0
  157. package/src/services/persistence/surrealdb.ts +10 -10
  158. package/src/services/stream-processor/index.ts +796 -84
  159. package/src/services/stream-processor/permissions.test.ts +47 -0
  160. package/src/services/stream-processor/permissions.ts +53 -0
  161. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  162. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  163. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  164. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  165. package/src/services/stream-processor/wasm-types.ts +59 -3
  166. package/src/services/tabs/broker-client.ts +283 -0
  167. package/src/services/tabs/broker.test.ts +327 -0
  168. package/src/services/tabs/coordinator.test.ts +365 -0
  169. package/src/services/tabs/coordinator.ts +633 -0
  170. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  171. package/src/services/tabs/leader-locks.ts +75 -0
  172. package/src/services/tabs/protocol.ts +262 -0
  173. package/src/services/tabs/support.ts +36 -0
  174. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  175. package/src/sp00ky.ts +5 -0
  176. package/src/state/client-state.ts +184 -0
  177. package/src/state/lifecycle.test.ts +118 -0
  178. package/src/state/lifecycle.ts +86 -0
  179. package/src/state/reducers.test.ts +280 -0
  180. package/src/state/reducers.ts +462 -0
  181. package/src/state/selectors.test.ts +149 -0
  182. package/src/state/selectors.ts +184 -0
  183. package/src/sync/connection.saga.test.ts +84 -0
  184. package/src/sync/connection.saga.ts +86 -0
  185. package/src/sync/live.saga.test.ts +69 -0
  186. package/src/sync/live.saga.ts +49 -0
  187. package/src/sync/policy.health.test.ts +56 -0
  188. package/src/sync/policy.test.ts +659 -0
  189. package/src/sync/policy.ts +507 -0
  190. package/src/sync/poll.saga.test.ts +51 -0
  191. package/src/sync/poll.saga.ts +36 -0
  192. package/src/sync/tabs.saga.test.ts +74 -0
  193. package/src/sync/tabs.saga.ts +92 -0
  194. package/src/testing/adapters.ts +133 -0
  195. package/src/testing/build.ts +61 -0
  196. package/src/testing/fake-services.ts +34 -0
  197. package/src/testing/run-pure.test.ts +92 -0
  198. package/src/testing/run-pure.ts +128 -0
  199. package/src/testing/services.ts +17 -0
  200. package/src/types.ts +557 -16
  201. package/src/utils/blurhash.ts +90 -0
  202. package/src/utils/error-classification.test.ts +54 -0
  203. package/src/utils/error-classification.ts +25 -0
  204. package/src/utils/index.ts +86 -13
  205. package/src/utils/parser.test.ts +49 -120
  206. package/src/utils/parser.ts +32 -2
  207. package/src/utils/semver.test.ts +32 -0
  208. package/src/utils/semver.ts +30 -0
  209. package/src/utils/surql.ts +30 -18
  210. package/src/utils/utils.test.ts +2 -1
  211. package/src/utils/withRetry.test.ts +1 -1
  212. package/tsdown.config.ts +86 -1
  213. package/vitest.config.ts +48 -0
  214. package/src/modules/cache/index.ts +0 -241
  215. package/src/modules/cache/types.ts +0 -19
  216. package/src/modules/data/data.test.ts +0 -58
  217. package/src/modules/data/index.ts +0 -777
  218. package/src/modules/sync/engine.ts +0 -163
  219. package/src/modules/sync/events/index.ts +0 -77
  220. package/src/modules/sync/index.ts +0 -3
  221. package/src/modules/sync/queue/index.ts +0 -2
  222. package/src/modules/sync/queue/queue-down.ts +0 -89
  223. package/src/modules/sync/queue/queue-up.ts +0 -223
  224. package/src/modules/sync/scheduler.ts +0 -84
  225. package/src/modules/sync/sync.ts +0 -407
  226. package/src/modules/sync/utils.test.ts +0 -311
  227. package/src/modules/sync/utils.ts +0 -171
  228. package/src/spooky.ts +0 -346
package/package.json CHANGED
@@ -1,46 +1,83 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.0-canary.1",
3
+ "version": "0.0.1-alpha.1",
4
4
  "type": "module",
5
+ "sideEffects": false,
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",
7
8
  "exports": {
8
9
  ".": {
9
10
  "types": "./dist/index.d.ts",
10
11
  "import": "./dist/index.js"
12
+ },
13
+ "./otel": {
14
+ "types": "./dist/otel/index.d.ts",
15
+ "import": "./dist/otel/index.js"
11
16
  }
12
17
  },
13
18
  "scripts": {
14
19
  "dev": "tsdown --watch",
15
20
  "dev:example": "pnpm dev",
16
- "build": "tsdown",
17
- "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"
18
26
  },
19
27
  "repository": {
20
28
  "type": "git",
21
- "url": "https://github.com/mono424/spooky.git",
29
+ "url": "https://github.com/mono424/sp00ky.git",
22
30
  "directory": "packages/core"
23
31
  },
32
+ "keywords": [
33
+ "surrealdb",
34
+ "local-first",
35
+ "sync",
36
+ "tanstack-intent"
37
+ ],
24
38
  "publishConfig": {
25
39
  "access": "public"
26
40
  },
27
- "dependencies": {
41
+ "peerDependencies": {
28
42
  "@opentelemetry/api": "^1.9.0",
29
43
  "@opentelemetry/exporter-logs-otlp-proto": "^0.211.0",
30
- "@opentelemetry/resources": "^2.5.0",
44
+ "@opentelemetry/resources": "^2.6.0",
31
45
  "@opentelemetry/sdk-logs": "^0.211.0",
32
- "@opentelemetry/semantic-conventions": "^1.39.0",
33
- "@spooky-sync/query-builder": "workspace:*",
34
- "@spooky-sync/ssp-wasm": "workspace:*",
35
- "@surrealdb/wasm": "^3.0.0",
46
+ "@opentelemetry/semantic-conventions": "^1.39.0"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@opentelemetry/api": {
50
+ "optional": true
51
+ },
52
+ "@opentelemetry/exporter-logs-otlp-proto": {
53
+ "optional": true
54
+ },
55
+ "@opentelemetry/resources": {
56
+ "optional": true
57
+ },
58
+ "@opentelemetry/sdk-logs": {
59
+ "optional": true
60
+ },
61
+ "@opentelemetry/semantic-conventions": {
62
+ "optional": true
63
+ }
64
+ },
65
+ "dependencies": {
66
+ "@spooky-sync/query-builder": "0.0.1-alpha.1",
67
+ "@spooky-sync/ssp-wasm": "0.0.1-alpha.1",
68
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
+ "@surrealdb/wasm": "^3.0.3",
70
+ "blurhash": "^2.0.5",
36
71
  "fast-json-patch": "^3.1.1",
72
+ "loro-crdt": "^1.5.6",
37
73
  "pino": "^10.1.0",
38
- "surrealdb": "2.0.0"
74
+ "surrealdb": "2.0.3"
39
75
  },
40
76
  "devDependencies": {
41
77
  "@types/node": "^22.5.2",
78
+ "@vitest/coverage-v8": "^3.2.4",
42
79
  "tsdown": "^0.12.4",
43
80
  "typescript": "^5.6.2",
44
81
  "vitest": "^3.2.0"
45
82
  }
46
- }
83
+ }
@@ -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');
@@ -0,0 +1,276 @@
1
+ ---
2
+ name: sp00ky-core
3
+ description: >-
4
+ Core client for the Sp00ky reactive local-first SurrealDB framework. Use when
5
+ initializing Sp00kyClient, registering queries and subscriptions, performing
6
+ mutations (create/update/delete), handling auth (signUp/signIn/signOut),
7
+ working with file buckets, or using backend runs (HTTP outbox pattern).
8
+ metadata:
9
+ author: sp00ky-sync
10
+ version: "0.0.1"
11
+ ---
12
+
13
+ # Sp00ky Core
14
+
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
+
17
+ ## Initialization
18
+
19
+ ```typescript
20
+ import { Sp00kyClient } from '@spooky-sync/core';
21
+ import { schema } from './generated/schema'; // Generated by `spooky generate`
22
+ import schemaSurql from './generated/schema.surql?raw';
23
+
24
+ const client = new Sp00kyClient({
25
+ database: {
26
+ endpoint: 'ws://localhost:8000',
27
+ namespace: 'my_ns',
28
+ database: 'my_db',
29
+ store: 'indexeddb', // or 'memory'
30
+ },
31
+ schema,
32
+ schemaSurql,
33
+ logLevel: 'info',
34
+ });
35
+
36
+ // IMPORTANT: You must call init() before using the client
37
+ await client.init();
38
+
39
+ // When done (e.g., app teardown):
40
+ await client.close();
41
+ ```
42
+
43
+ ### Sp00kyConfig Options
44
+
45
+ See [references/config.md](references/config.md) for all configuration options.
46
+
47
+ Key fields:
48
+ - `database.endpoint` — SurrealDB WebSocket URL
49
+ - `database.namespace` / `database.database` — SurrealDB namespace and database
50
+ - `database.store` — `'indexeddb'` (persistent) or `'memory'` (transient)
51
+ - `schema` — The `SchemaStructure` object (use `as const satisfies SchemaStructure`)
52
+ - `schemaSurql` — The compiled SURQL schema string for local DB provisioning
53
+ - `logLevel` — `'debug' | 'info' | 'warn' | 'error'`
54
+ - `persistenceClient` — `'surrealdb'` (default), `'localstorage'`, or a custom `PersistenceClient`
55
+ - `streamDebounceTime` — Debounce ms for stream updates (default: 100)
56
+
57
+ ## Querying
58
+
59
+ Queries are registered with the client and return a hash. You subscribe to the hash to receive reactive updates.
60
+
61
+ ```typescript
62
+ // Register a query — returns a QueryBuilder
63
+ const finalQuery = client.query('post', {})
64
+ .where({ author: 'user:alice' })
65
+ .orderBy('createdAt', 'desc')
66
+ .limit(20)
67
+ .build();
68
+
69
+ // Run it to get the hash
70
+ const { hash } = await finalQuery.run();
71
+
72
+ // Subscribe to reactive updates
73
+ const unsubscribe = await client.subscribe(hash, (records) => {
74
+ console.log('Posts:', records);
75
+ }, { immediate: true });
76
+
77
+ // Later: unsubscribe
78
+ unsubscribe();
79
+ ```
80
+
81
+ ### Query TTL
82
+
83
+ Queries have a time-to-live. Supported values: `'1m'`, `'5m'`, `'10m'` (default), `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`–`'12h'`, `'1d'`.
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
+
94
+ ## Mutations
95
+
96
+ Mutations are optimistic and automatically synced to the remote SurrealDB instance.
97
+
98
+ ```typescript
99
+ // Create — pass a full "table:id" string and the record data
100
+ await client.create('post:abc123', {
101
+ title: 'Hello World',
102
+ body: 'My first post',
103
+ author: 'user:alice',
104
+ });
105
+
106
+ // Update — pass table name, record ID, and partial data
107
+ await client.update('post', 'post:abc123', { title: 'Updated Title' });
108
+
109
+ // Update with debounce (useful for text fields)
110
+ await client.update('post', 'post:abc123', { body: 'typing...' }, {
111
+ debounced: { key: 'recordId_x_fields', delay: 300 },
112
+ });
113
+
114
+ // Delete
115
+ await client.delete('post', 'post:abc123');
116
+ ```
117
+
118
+ ### Debounced Updates
119
+
120
+ Use `debounced` option for frequent updates (e.g., live typing):
121
+ - `key: 'recordId'` — Debounce by record ID only (latest write wins, no merge)
122
+ - `key: 'recordId_x_fields'` — Debounce by record ID + changed fields (recommended)
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
+
133
+ ## Authentication
134
+
135
+ See [references/auth.md](references/auth.md) for the full auth API.
136
+
137
+ ```typescript
138
+ // Sign up
139
+ await client.auth.signUp('user_access', {
140
+ email: 'alice@example.com',
141
+ password: 'secret',
142
+ name: 'Alice',
143
+ });
144
+
145
+ // Sign in
146
+ await client.auth.signIn('user_access', {
147
+ email: 'alice@example.com',
148
+ password: 'secret',
149
+ });
150
+
151
+ // Subscribe to auth state changes
152
+ const unsub = client.auth.subscribe((userId) => {
153
+ console.log('Auth state:', userId); // string ID or null
154
+ });
155
+
156
+ // Sign out
157
+ await client.auth.signOut();
158
+ ```
159
+
160
+ Auth state properties: `client.auth.token`, `client.auth.currentUser`, `client.auth.isAuthenticated`, `client.auth.isLoading`.
161
+
162
+ ## Buckets (File Storage)
163
+
164
+ ```typescript
165
+ const avatars = client.bucket('avatars');
166
+
167
+ await avatars.put('alice/profile.png', fileBytes);
168
+ const content = await avatars.get('alice/profile.png');
169
+ const exists = await avatars.exists('alice/profile.png');
170
+ await avatars.delete('alice/profile.png');
171
+ await avatars.copy('alice/profile.png', 'alice/backup.png');
172
+ await avatars.rename('old.png', 'new.png');
173
+ const files = await avatars.list('alice/');
174
+ ```
175
+
176
+ Buckets must be defined in the schema under the `buckets` array.
177
+
178
+ ## Backend Runs (HTTP Outbox)
179
+
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.
181
+
182
+ ### `db.run()` Signature
183
+
184
+ ```typescript
185
+ db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
186
+ backend: B, // Backend name from sp00ky.yml (e.g., 'api')
187
+ route: R, // Route path from OpenAPI spec (e.g., '/spookify')
188
+ payload: RoutePayload<S, B, R>, // Typed args for the route
189
+ options?: RunOptions
190
+ ): Promise<void>
191
+ ```
192
+
193
+ All parameters are type-safe — `backend`, `route`, and `payload` are inferred from the generated schema.
194
+
195
+ ### RunOptions
196
+
197
+ | Option | Type | Default | Description |
198
+ |--------|------|---------|-------------|
199
+ | `assignedTo` | `string` | `undefined` | Record ID to link the job to an entity (e.g., `'thread:abc'`) |
200
+ | `max_retries` | `number` | `3` | Maximum retry attempts on failure |
201
+ | `retry_strategy` | `'linear' \| 'exponential'` | `'linear'` | Backoff strategy between retries |
202
+
203
+ ### Entity Linking via `assignedTo`
204
+
205
+ The `assignedTo` option is how you link a job to an entity. Passing a record ID:
206
+
207
+ - Sets the `assigned_to` field on the job record (typed as `record<thread>` etc. in the outbox schema)
208
+ - Enables **permission scoping** — e.g., `WHERE assigned_to.author.id = $auth.id` restricts job visibility to the entity's owner
209
+ - Allows **querying jobs via relationships** — use `.related('jobs')` on the parent entity to reactively track job status
210
+
211
+ ### Full Example
212
+
213
+ **1. Configure the backend in `sp00ky.yml`:**
214
+
215
+ ```yaml
216
+ backends:
217
+ api:
218
+ type: http
219
+ spec: ../api/openapi.yml
220
+ baseUrl: http://host.docker.internal:3660
221
+ auth:
222
+ type: token
223
+ token: MY_SECRET
224
+ method:
225
+ type: outbox
226
+ table: job
227
+ schema: ./src/outbox/api.surql
228
+ ```
229
+
230
+ **2. Define the outbox table in SurrealQL** (`src/outbox/api.surql`):
231
+
232
+ ```sql
233
+ DEFINE TABLE job SCHEMAFULL
234
+ PERMISSIONS
235
+ FOR select, create, update, delete
236
+ WHERE $access = "account" AND assigned_to.author.id = $auth.id;
237
+
238
+ DEFINE FIELD assigned_to ON TABLE job TYPE record<thread>;
239
+ DEFINE FIELD path ON TABLE job TYPE string;
240
+ DEFINE FIELD payload ON TABLE job TYPE any;
241
+ DEFINE FIELD retries ON TABLE job TYPE int DEFAULT ALWAYS 0;
242
+ DEFINE FIELD max_retries ON TABLE job TYPE int DEFAULT ALWAYS 3;
243
+ DEFINE FIELD retry_strategy ON TABLE job TYPE string DEFAULT ALWAYS "linear"
244
+ ASSERT $value IN ["linear", "exponential"];
245
+ DEFINE FIELD status ON TABLE job TYPE string DEFAULT ALWAYS "pending"
246
+ ASSERT $value IN ["pending", "processing", "success", "failed"];
247
+ DEFINE FIELD errors ON TABLE job TYPE array<object> DEFAULT ALWAYS [];
248
+ DEFINE FIELD created_at ON TABLE job TYPE datetime VALUE time::now();
249
+ ```
250
+
251
+ **3. Run `spooky generate`** — this produces the typed `backends` block in `schema.gen.ts`.
252
+
253
+ **4. Call `db.run()` with entity linking:**
254
+
255
+ ```typescript
256
+ // Trigger the /spookify backend route, linked to a thread
257
+ await client.run('api', '/spookify', { id: threadId }, {
258
+ assignedTo: threadId, // Links job to the thread entity
259
+ });
260
+ ```
261
+
262
+ ### How It Works Under the Hood
263
+
264
+ When you call `db.run('api', '/spookify', payload, options)`:
265
+
266
+ 1. Validates the route exists in the schema and all required args are present
267
+ 2. Creates a record in the outbox table (`job`) with fields: `path`, `payload` (JSON-stringified), `max_retries`, `retry_strategy`, and optionally `assigned_to`
268
+ 3. The record is created via the standard `client.create()` path — optimistic, synced to remote
269
+ 4. SSP detects the new job, calls your backend HTTP API, and updates the job's `status` field (`pending` → `processing` → `success`/`failed`)
270
+
271
+ ## Common Pitfalls
272
+
273
+ 1. **Must call `init()` before using the client** — All operations will fail if you skip initialization.
274
+ 2. **Schema is generated** — Use `spooky generate` to produce the schema TypeScript file and SURQL file from your `.surql` definitions.
275
+ 3. **RecordId format** — SurrealDB uses `table:id` format. When creating records, pass the full ID (e.g., `'post:abc123'`). The framework auto-converts string IDs to `RecordId` objects internally.
276
+ 4. **Queries are hash-based** — The same query always produces the same hash, enabling deduplication and caching.
@@ -0,0 +1,98 @@
1
+ # Authentication Reference
2
+
3
+ ## AuthService API
4
+
5
+ The `AuthService` is available on `Sp00kyClient` as `client.auth`.
6
+
7
+ ### Properties
8
+
9
+ | Property | Type | Description |
10
+ |----------|------|-------------|
11
+ | `token` | `string \| null` | Current auth token |
12
+ | `currentUser` | `any \| null` | Current authenticated user record |
13
+ | `isAuthenticated` | `boolean` | Whether a user is authenticated |
14
+ | `isLoading` | `boolean` | Whether auth state is being validated |
15
+
16
+ ### Methods
17
+
18
+ #### `signUp(accessName, params)`
19
+
20
+ Create a new account using the named access definition from the schema.
21
+
22
+ ```typescript
23
+ await client.auth.signUp('user_access', {
24
+ email: 'alice@example.com',
25
+ password: 'secret123',
26
+ name: 'Alice',
27
+ });
28
+ ```
29
+
30
+ The access definition in the schema determines what parameters are required:
31
+
32
+ ```typescript
33
+ access: {
34
+ user_access: {
35
+ signup: {
36
+ params: {
37
+ email: { type: 'string', optional: false },
38
+ password: { type: 'string', optional: false },
39
+ name: { type: 'string', optional: true },
40
+ },
41
+ },
42
+ signIn: {
43
+ params: {
44
+ email: { type: 'string', optional: false },
45
+ password: { type: 'string', optional: false },
46
+ },
47
+ },
48
+ },
49
+ }
50
+ ```
51
+
52
+ Parameters are fully type-safe — TypeScript will infer the correct types from the schema.
53
+
54
+ #### `signIn(accessName, params)`
55
+
56
+ Authenticate with existing credentials.
57
+
58
+ ```typescript
59
+ await client.auth.signIn('user_access', {
60
+ email: 'alice@example.com',
61
+ password: 'secret123',
62
+ });
63
+ ```
64
+
65
+ #### `signOut()`
66
+
67
+ Sign out, clear the session token, and notify subscribers.
68
+
69
+ ```typescript
70
+ await client.auth.signOut();
71
+ ```
72
+
73
+ #### `subscribe(callback)`
74
+
75
+ Subscribe to auth state changes. The callback fires immediately with the current state, then on every change.
76
+
77
+ ```typescript
78
+ const unsub = client.auth.subscribe((userId: string | null) => {
79
+ if (userId) {
80
+ console.log('Logged in as', userId);
81
+ } else {
82
+ console.log('Not authenticated');
83
+ }
84
+ });
85
+
86
+ // Cleanup
87
+ unsub();
88
+ ```
89
+
90
+ #### `check(accessToken?)`
91
+
92
+ Manually re-validate the current session. Called automatically during `init()`.
93
+
94
+ ```typescript
95
+ await client.auth.check();
96
+ // Or with a specific token:
97
+ await client.auth.check('eyJhbGciOiJIUzI1NiJ9...');
98
+ ```
@@ -0,0 +1,76 @@
1
+ # Sp00kyConfig Reference
2
+
3
+ ```typescript
4
+ interface Sp00kyConfig<S extends SchemaStructure> {
5
+ database: {
6
+ /** SurrealDB WebSocket endpoint URL */
7
+ endpoint?: string;
8
+ /** SurrealDB namespace */
9
+ namespace: string;
10
+ /** SurrealDB database name */
11
+ database: string;
12
+ /** Local store type: 'memory' (transient) or 'indexeddb' (persistent) */
13
+ store?: 'memory' | 'indexeddb';
14
+ /** Authentication token */
15
+ token?: string;
16
+ };
17
+ /** Unique client identifier. Auto-generated if not provided. */
18
+ clientId?: string;
19
+ /** The schema object (must use `as const satisfies SchemaStructure`) */
20
+ schema: S;
21
+ /** Compiled SURQL schema string for local DB provisioning */
22
+ schemaSurql: string;
23
+ /** Logging level */
24
+ logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal' | 'silent' | 'trace';
25
+ /**
26
+ * Persistence client for local storage.
27
+ * - 'surrealdb': Uses the local SurrealDB instance (default)
28
+ * - 'localstorage': Uses browser localStorage
29
+ * - Custom: Provide an object implementing PersistenceClient
30
+ */
31
+ persistenceClient?: PersistenceClient | 'surrealdb' | 'localstorage';
32
+ /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel) */
33
+ otelTransmit?: PinoTransmit;
34
+ /** Debounce time in ms for stream updates (default: 100) */
35
+ streamDebounceTime?: number;
36
+ }
37
+ ```
38
+
39
+ ## PersistenceClient Interface
40
+
41
+ ```typescript
42
+ interface PersistenceClient {
43
+ set<T>(key: string, value: T): Promise<void>;
44
+ get<T>(key: string): Promise<T | null>;
45
+ remove(key: string): Promise<void>;
46
+ }
47
+ ```
48
+
49
+ ## QueryTimeToLive Values
50
+
51
+ Supported TTL values for `client.query()`:
52
+
53
+ `'1m'`, `'5m'`, `'10m'`, `'15m'`, `'20m'`, `'25m'`, `'30m'`, `'1h'`, `'2h'`, `'3h'`, `'4h'`, `'5h'`, `'6h'`, `'7h'`, `'8h'`, `'9h'`, `'10h'`, `'11h'`, `'12h'`, `'1d'`
54
+
55
+ ## RunOptions
56
+
57
+ ```typescript
58
+ interface RunOptions {
59
+ assignedTo?: string;
60
+ max_retries?: number;
61
+ retry_strategy?: 'linear' | 'exponential';
62
+ }
63
+ ```
64
+
65
+ ## UpdateOptions
66
+
67
+ ```typescript
68
+ interface UpdateOptions {
69
+ debounced?: boolean | {
70
+ /** 'recordId': latest write wins. 'recordId_x_fields': merge by field (recommended). */
71
+ key?: 'recordId' | 'recordId_x_fields';
72
+ /** Debounce delay in ms */
73
+ delay?: number;
74
+ };
75
+ }
76
+ ```
@@ -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
+ });