@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320

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 (259) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15137 -2434
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +337 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1811 -38
  19. package/dist/worker-edges.js +439 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +180 -0
  32. package/src/boot/boot.saga.ts +142 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +51 -0
  46. package/src/client/services.ts +404 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +163 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +478 -0
  62. package/src/live/index.ts +1013 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +169 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +91 -0
  90. package/src/modules/ref-tables.ts +88 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +65 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/env.test.ts +23 -0
  110. package/src/query/env.ts +56 -0
  111. package/src/query/fetch.saga.test.ts +199 -0
  112. package/src/query/fetch.saga.ts +120 -0
  113. package/src/query/hash.test.ts +14 -0
  114. package/src/query/hash.ts +18 -0
  115. package/src/query/id-set-plan.test.ts +132 -0
  116. package/src/query/lifecycle.saga.test.ts +274 -0
  117. package/src/query/lifecycle.saga.ts +159 -0
  118. package/src/query/materialize.saga.test.ts +115 -0
  119. package/src/query/materialize.saga.ts +84 -0
  120. package/src/query/materialize.test.ts +58 -0
  121. package/src/query/materialize.ts +57 -0
  122. package/src/query/membership.saga.test.ts +350 -0
  123. package/src/query/membership.saga.ts +278 -0
  124. package/src/query/membership.test.ts +118 -0
  125. package/src/query/membership.ts +201 -0
  126. package/src/query/register.saga.test.ts +311 -0
  127. package/src/query/register.saga.ts +229 -0
  128. package/src/query/render-set.test.ts +47 -0
  129. package/src/query/render-set.ts +71 -0
  130. package/src/query/sql.test.ts +81 -0
  131. package/src/query/sql.ts +112 -0
  132. package/src/query/window-query.test.ts +70 -0
  133. package/src/query/window-query.ts +195 -0
  134. package/src/services/blobs/blob-cache.test.ts +359 -0
  135. package/src/services/blobs/blob-cache.ts +603 -0
  136. package/src/services/blobs/blob-manifest.ts +227 -0
  137. package/src/services/blobs/blob-store.test.ts +77 -0
  138. package/src/services/blobs/blob-store.ts +359 -0
  139. package/src/services/blobs/blob.fixture.ts +90 -0
  140. package/src/services/blobs/index.ts +70 -0
  141. package/src/services/database/cache-engine.ts +199 -0
  142. package/src/services/database/connection-supervisor.test.ts +318 -0
  143. package/src/services/database/connection-supervisor.ts +422 -0
  144. package/src/services/database/database.query-timeout.test.ts +140 -0
  145. package/src/services/database/database.responses.test.ts +43 -0
  146. package/src/services/database/database.ts +146 -61
  147. package/src/services/database/engine-factory.ts +33 -0
  148. package/src/services/database/errors.ts +34 -0
  149. package/src/services/database/events/index.ts +2 -1
  150. package/src/services/database/index.ts +7 -0
  151. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  152. package/src/services/database/local-migrator.integration.test.ts +110 -0
  153. package/src/services/database/local-migrator.ts +59 -16
  154. package/src/services/database/local.test.ts +64 -0
  155. package/src/services/database/local.ts +487 -63
  156. package/src/services/database/plan-render.test.ts +159 -0
  157. package/src/services/database/plan-render.ts +108 -0
  158. package/src/services/database/relation-resolver.test.ts +484 -0
  159. package/src/services/database/relation-resolver.ts +0 -0
  160. package/src/services/database/remote.ts +255 -6
  161. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  162. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  163. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  164. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  165. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  166. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  167. package/src/services/database/sqlite-lock-verify.ts +45 -0
  168. package/src/services/database/sqlite-open.test.ts +150 -0
  169. package/src/services/database/sqlite-open.ts +164 -0
  170. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  171. package/src/services/database/sqlite-plan-sql.ts +182 -0
  172. package/src/services/database/sqlite-projection.test.ts +121 -0
  173. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  174. package/src/services/database/sqlite-select.test.ts +261 -0
  175. package/src/services/database/sqlite-select.ts +131 -0
  176. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  177. package/src/services/database/sqlite-transport.ts +224 -0
  178. package/src/services/database/sqlite-worker.ts +437 -0
  179. package/src/services/database/surql-translate.ts +416 -0
  180. package/src/services/database/surreal-cache-engine.ts +161 -0
  181. package/src/services/logger/index.test.ts +132 -0
  182. package/src/services/logger/index.ts +189 -7
  183. package/src/services/persistence/localstorage.ts +2 -2
  184. package/src/services/persistence/resilient.ts +10 -3
  185. package/src/services/persistence/surrealdb.ts +4 -4
  186. package/src/services/stream-processor/index.ts +783 -68
  187. package/src/services/stream-processor/permissions.test.ts +47 -0
  188. package/src/services/stream-processor/permissions.ts +53 -0
  189. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  190. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  191. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  192. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  193. package/src/services/stream-processor/wasm-types.ts +58 -2
  194. package/src/services/tabs/broker-client.ts +283 -0
  195. package/src/services/tabs/broker.test.ts +327 -0
  196. package/src/services/tabs/coordinator.test.ts +386 -0
  197. package/src/services/tabs/coordinator.ts +638 -0
  198. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  199. package/src/services/tabs/leader-locks.ts +75 -0
  200. package/src/services/tabs/protocol.ts +262 -0
  201. package/src/services/tabs/support.ts +36 -0
  202. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  203. package/src/sp00ky.ts +5 -395
  204. package/src/state/client-state.ts +200 -0
  205. package/src/state/lifecycle.test.ts +141 -0
  206. package/src/state/lifecycle.ts +99 -0
  207. package/src/state/reducers.test.ts +304 -0
  208. package/src/state/reducers.ts +526 -0
  209. package/src/state/selectors.test.ts +175 -0
  210. package/src/state/selectors.ts +222 -0
  211. package/src/sw/index.test.ts +582 -0
  212. package/src/sw/index.ts +607 -0
  213. package/src/sw/store.ts +96 -0
  214. package/src/sync/connection.saga.test.ts +86 -0
  215. package/src/sync/connection.saga.ts +85 -0
  216. package/src/sync/live.saga.test.ts +178 -0
  217. package/src/sync/live.saga.ts +107 -0
  218. package/src/sync/policy.health.test.ts +56 -0
  219. package/src/sync/policy.test.ts +659 -0
  220. package/src/sync/policy.ts +502 -0
  221. package/src/sync/poll.saga.test.ts +51 -0
  222. package/src/sync/poll.saga.ts +36 -0
  223. package/src/sync/tabs.saga.test.ts +78 -0
  224. package/src/sync/tabs.saga.ts +96 -0
  225. package/src/testing/adapters.ts +133 -0
  226. package/src/testing/build.ts +63 -0
  227. package/src/testing/fake-services.ts +48 -0
  228. package/src/testing/run-pure.test.ts +92 -0
  229. package/src/testing/run-pure.ts +128 -0
  230. package/src/testing/services.ts +17 -0
  231. package/src/types.ts +624 -10
  232. package/src/utils/blurhash.ts +90 -0
  233. package/src/utils/edges.ts +39 -0
  234. package/src/utils/error-classification.test.ts +54 -0
  235. package/src/utils/error-classification.ts +25 -0
  236. package/src/utils/index.ts +78 -12
  237. package/src/utils/parser.test.ts +67 -102
  238. package/src/utils/parser.ts +64 -2
  239. package/src/utils/semver.test.ts +32 -0
  240. package/src/utils/semver.ts +30 -0
  241. package/src/utils/sha256.ts +15 -0
  242. package/src/utils/surql.ts +30 -18
  243. package/src/utils/utils.test.ts +3 -2
  244. package/tsdown.config.ts +117 -3
  245. package/vitest.config.ts +48 -0
  246. package/src/modules/cache/index.ts +0 -241
  247. package/src/modules/cache/types.ts +0 -19
  248. package/src/modules/data/data.test.ts +0 -58
  249. package/src/modules/data/index.ts +0 -879
  250. package/src/modules/sync/engine.ts +0 -173
  251. package/src/modules/sync/events/index.ts +0 -77
  252. package/src/modules/sync/index.ts +0 -3
  253. package/src/modules/sync/queue/index.ts +0 -2
  254. package/src/modules/sync/queue/queue-down.ts +0 -89
  255. package/src/modules/sync/queue/queue-up.ts +0 -223
  256. package/src/modules/sync/scheduler.ts +0 -84
  257. package/src/modules/sync/sync.ts +0 -421
  258. package/src/modules/sync/utils.test.ts +0 -311
  259. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,73 @@
1
+ //#region src/push/types.ts
2
+ /**
3
+ * Web Push wire types shared by the page (`db.webPush`), the service worker
4
+ * bridge (`@spooky-sync/core/sw`) and the live feed. Pure: types plus a few
5
+ * dependency-free helpers, safe in any context.
6
+ *
7
+ * `PushPayload` mirrors `packages/push-core/src/config.rs` (`PushPayload`,
8
+ * `PayloadKind`, `Op`, `NotificationTemplate`). Keep the two in step.
9
+ */
10
+ /** Payload format version this build understands. */
11
+ const PUSH_PAYLOAD_VERSION = 1;
12
+ /** `urlBase64ToUint8Array`: the VAPID key as `applicationServerKey` wants it. */
13
+ function base64UrlToBytes(value) {
14
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4);
15
+ const raw = typeof atob === "function" ? atob(padded) : decodeBase64Fallback(padded);
16
+ const out = new Uint8Array(raw.length);
17
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
18
+ return out;
19
+ }
20
+ function bytesToBase64Url(bytes) {
21
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
22
+ let raw = "";
23
+ for (let i = 0; i < view.length; i++) raw += String.fromCharCode(view[i]);
24
+ return (typeof btoa === "function" ? btoa(raw) : encodeBase64Fallback(raw)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ }
26
+ function sameBytes(a, b) {
27
+ if (!a || !b) return false;
28
+ const x = a instanceof Uint8Array ? a : new Uint8Array(a);
29
+ const y = b instanceof Uint8Array ? b : new Uint8Array(b);
30
+ if (x.length !== y.length) return false;
31
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
32
+ return true;
33
+ }
34
+ /** A payload this build can act on (`v` known, `kind` set). */
35
+ function isPushPayload(value) {
36
+ if (!value || typeof value !== "object") return false;
37
+ const v = value;
38
+ return v.v === PUSH_PAYLOAD_VERSION && (v.kind === "rule" || v.kind === "message");
39
+ }
40
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
41
+ function decodeBase64Fallback(input) {
42
+ const clean = input.replace(/=+$/, "");
43
+ let out = "";
44
+ let buffer = 0;
45
+ let bits = 0;
46
+ for (const ch of clean) {
47
+ const idx = B64.indexOf(ch);
48
+ if (idx < 0) continue;
49
+ buffer = buffer << 6 | idx;
50
+ bits += 6;
51
+ if (bits >= 8) {
52
+ bits -= 8;
53
+ out += String.fromCharCode(buffer >> bits & 255);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ function encodeBase64Fallback(raw) {
59
+ let out = "";
60
+ for (let i = 0; i < raw.length; i += 3) {
61
+ const a = raw.charCodeAt(i);
62
+ const b = i + 1 < raw.length ? raw.charCodeAt(i + 1) : 0;
63
+ const c = i + 2 < raw.length ? raw.charCodeAt(i + 2) : 0;
64
+ const n = a << 16 | b << 8 | c;
65
+ out += B64[n >> 18 & 63] + B64[n >> 12 & 63];
66
+ out += i + 1 < raw.length ? B64[n >> 6 & 63] : "=";
67
+ out += i + 2 < raw.length ? B64[n & 63] : "=";
68
+ }
69
+ return out;
70
+ }
71
+
72
+ //#endregion
73
+ export { sameBytes as a, isPushPayload as i, base64UrlToBytes as n, bytesToBase64Url as r, PUSH_PAYLOAD_VERSION as t };
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.32",
3
+ "version": "0.0.1-canary.320",
4
4
  "type": "module",
5
+ "sideEffects": false,
5
6
  "main": "./dist/index.js",
6
7
  "types": "./dist/index.d.ts",
7
8
  "exports": {
@@ -12,13 +13,28 @@
12
13
  "./otel": {
13
14
  "types": "./dist/otel/index.d.ts",
14
15
  "import": "./dist/otel/index.js"
16
+ },
17
+ "./pure": {
18
+ "types": "./dist/pure.d.ts",
19
+ "import": "./dist/pure.js"
20
+ },
21
+ "./live": {
22
+ "types": "./dist/live.d.ts",
23
+ "import": "./dist/live.js"
24
+ },
25
+ "./sw": {
26
+ "types": "./dist/sw.d.ts",
27
+ "import": "./dist/sw.js"
15
28
  }
16
29
  },
17
30
  "scripts": {
18
31
  "dev": "tsdown --watch",
19
32
  "dev:example": "pnpm dev",
20
- "build": "tsdown",
21
- "test": "vitest"
33
+ "build": "tsdown && node scripts/check-broker-bundle.mjs && node scripts/check-worker-entries.mjs",
34
+ "test": "vitest",
35
+ "test:run": "vitest run",
36
+ "test:coverage": "vitest run --coverage",
37
+ "type-check": "tsc --noEmit"
22
38
  },
23
39
  "repository": {
24
40
  "type": "git",
@@ -59,15 +75,19 @@
59
75
  }
60
76
  },
61
77
  "dependencies": {
62
- "@spooky-sync/query-builder": "workspace:*",
63
- "@spooky-sync/ssp-wasm": "workspace:*",
64
- "@surrealdb/wasm": "^3.0.0",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.320",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.320",
80
+ "@sqlite.org/sqlite-wasm": "3.53.0-build1",
81
+ "@surrealdb/wasm": "^3.0.3",
82
+ "blurhash": "^2.0.5",
65
83
  "fast-json-patch": "^3.1.1",
84
+ "loro-crdt": "^1.5.6",
66
85
  "pino": "^10.1.0",
67
- "surrealdb": "2.0.0"
86
+ "surrealdb": "2.1.0"
68
87
  },
69
88
  "devDependencies": {
70
89
  "@types/node": "^22.5.2",
90
+ "@vitest/coverage-v8": "^3.2.4",
71
91
  "tsdown": "^0.12.4",
72
92
  "typescript": "^5.6.2",
73
93
  "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');
@@ -0,0 +1,172 @@
1
+ // Build gate for the wasm-free subpath entries: dist/pure.js, dist/live.js and
2
+ // dist/sw.js (`@spooky-sync/core/pure`, `/live`, `/sw`). They are imported by
3
+ // service workers, dedicated workers and Node, so across each entry and every
4
+ // chunk it reaches:
5
+ // - no import of a wasm engine, the circuit, CRDTs or the logger;
6
+ // - no reference to `window`, `document` or `localStorage` (a service worker
7
+ // has none of them; an access at module top level would throw on import);
8
+ // - `/sw` does not even pull in the `surrealdb` SDK.
9
+ // Each entry is then really imported in this Node process (no DOM globals)
10
+ // to catch top-level side effects the static scan cannot see. Prints the
11
+ // gzipped size of each entry (own code, and fully bundled with its npm
12
+ // dependencies when rolldown is reachable). Run after `tsdown` in the build.
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { gzipSync } from 'node:zlib';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const dist = join(root, 'dist');
21
+
22
+ const ENTRIES = ['pure.js', 'live.js', 'sw.js'];
23
+ const FORBIDDEN_IMPORTS = [
24
+ '@spooky-sync/ssp-wasm',
25
+ '@surrealdb/wasm',
26
+ '@sqlite.org/sqlite-wasm',
27
+ 'loro-crdt',
28
+ 'pino',
29
+ 'blurhash',
30
+ ];
31
+ const FORBIDDEN_PER_ENTRY = { 'sw.js': ['surrealdb'] };
32
+ const FORBIDDEN_GLOBALS = ['window', 'document', 'localStorage'];
33
+
34
+ const importRe =
35
+ /(?:import|export)\s*(?:[^'"`;]*?\sfrom\s*)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
36
+
37
+ /** Blank out comments and string/template literals so identifier scans see code only. */
38
+ function codeOnly(src) {
39
+ let out = '';
40
+ let i = 0;
41
+ while (i < src.length) {
42
+ const c = src[i];
43
+ const n = src[i + 1];
44
+ if (c === '/' && n === '/') {
45
+ while (i < src.length && src[i] !== '\n') i++;
46
+ continue;
47
+ }
48
+ if (c === '/' && n === '*') {
49
+ i += 2;
50
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if (c === '"' || c === "'" || c === '`') {
55
+ const q = c;
56
+ i++;
57
+ while (i < src.length && src[i] !== q) i += src[i] === '\\' ? 2 : 1;
58
+ i++;
59
+ out += '""';
60
+ continue;
61
+ }
62
+ out += c;
63
+ i++;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function walk(entry) {
69
+ const seen = new Set();
70
+ const bare = new Set();
71
+ const stack = [join(dist, entry)];
72
+ while (stack.length > 0) {
73
+ const file = stack.pop();
74
+ if (seen.has(file)) continue;
75
+ seen.add(file);
76
+ if (!existsSync(file)) throw new Error(`${entry}: missing ${file}`);
77
+ const src = readFileSync(file, 'utf8');
78
+ for (const m of src.matchAll(importRe)) {
79
+ const spec = m[1] ?? m[2];
80
+ if (spec.startsWith('.')) stack.push(resolve(dirname(file), spec));
81
+ else bare.add(spec);
82
+ }
83
+ }
84
+ return { files: [...seen], bare: [...bare] };
85
+ }
86
+
87
+ const problems = [];
88
+ const sizes = [];
89
+
90
+ for (const entry of ENTRIES) {
91
+ let graph;
92
+ try {
93
+ graph = walk(entry);
94
+ } catch (error) {
95
+ problems.push(String(error.message ?? error));
96
+ continue;
97
+ }
98
+ const forbidden = [...FORBIDDEN_IMPORTS, ...(FORBIDDEN_PER_ENTRY[entry] ?? [])];
99
+ for (const spec of graph.bare) {
100
+ if (forbidden.some((f) => spec === f || spec.startsWith(`${f}/`)))
101
+ problems.push(`${entry} imports ${spec}`);
102
+ }
103
+ let own = '';
104
+ for (const file of graph.files) {
105
+ const src = readFileSync(file, 'utf8');
106
+ own += src;
107
+ if (/\.wasm\b/.test(src))
108
+ problems.push(`${entry}: ${file.slice(dist.length + 1)} references a .wasm file`);
109
+ const code = codeOnly(src);
110
+ for (const name of FORBIDDEN_GLOBALS) {
111
+ // `x.window` (a property) is fine; a bare identifier is not.
112
+ const re = new RegExp(`(?<![.\\w$])${name}(?![\\w$])`);
113
+ if (re.test(code))
114
+ problems.push(`${entry}: ${file.slice(dist.length + 1)} references \`${name}\``);
115
+ }
116
+ }
117
+ sizes.push({
118
+ entry,
119
+ files: graph.files.length,
120
+ raw: Buffer.byteLength(own),
121
+ gzip: gzipSync(own).length,
122
+ deps: graph.bare,
123
+ });
124
+ }
125
+
126
+ // Import each entry for real: top-level code must run without DOM globals.
127
+ for (const entry of ENTRIES) {
128
+ try {
129
+ await import(pathToFileURL(join(dist, entry)).href);
130
+ } catch (error) {
131
+ problems.push(`${entry} fails to import in Node: ${error?.message ?? error}`);
132
+ }
133
+ }
134
+
135
+ // Fully bundled size (entry + npm deps, minified), when rolldown is reachable
136
+ // through tsdown. Informational only.
137
+ async function bundledGzip(entry) {
138
+ try {
139
+ const req = createRequire(createRequire(join(root, 'package.json')).resolve('tsdown'));
140
+ const { rolldown } = await import(pathToFileURL(req.resolve('rolldown')).href);
141
+ const bundle = await rolldown({
142
+ input: join(dist, entry),
143
+ platform: 'browser',
144
+ logLevel: 'silent',
145
+ });
146
+ const { output } = await bundle.generate({ format: 'esm', minify: true });
147
+ await bundle.close();
148
+ const code = output.map((o) => o.code ?? '').join('');
149
+ return gzipSync(code).length;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ for (const s of sizes) s.bundled = await bundledGzip(s.entry);
156
+
157
+ const kb = (n) => `${(n / 1024).toFixed(2)} kB`;
158
+ for (const s of sizes) {
159
+ const bundled = s.bundled === null ? '' : `, bundled with deps + minified ${kb(s.bundled)} gzip`;
160
+ console.log(
161
+ `check-worker-entries: ${s.entry.padEnd(8)} ${kb(s.raw)} (${kb(s.gzip)} gzip, ${s.files} file${s.files === 1 ? '' : 's'}; deps: ${s.deps.join(', ') || 'none'})${bundled}`
162
+ );
163
+ }
164
+
165
+ if (problems.length > 0) {
166
+ for (const p of problems) console.error(`check-worker-entries: ${p}`);
167
+ console.error(
168
+ 'The /pure, /live and /sw entries must stay free of wasm, the logger and DOM globals.'
169
+ );
170
+ process.exit(1);
171
+ }
172
+ console.log('check-worker-entries: ok');
@@ -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.
@@ -111,6 +120,19 @@ await client.delete('post', 'post:abc123');
111
120
  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)
123
+ - `flushOnHide` (default `true`): also keep the merged patch in the local `_00_pending_writes`
124
+ table and push it as soon as the page is hidden (tab switch, reload, close). A reload or closed
125
+ tab inside the delay no longer loses the edit: the next boot moves the leftover patch into the
126
+ outbox. Set `false` for throwaway values (cursor position) to keep the patch in memory only.
127
+
128
+ ### Failed writes
129
+
130
+ A push the server rejects (permission, validation) is rolled back locally
131
+ (create, update and delete) and moved to the `_00_failed_mutations` tray in the
132
+ same local transaction. Network errors never do this; they retry. Inspect and
133
+ act on the tray with `db.failedMutationCount`, `db.subscribeToFailedMutations(cb)`,
134
+ `db.listFailedMutations()`, `db.retryFailedMutation(id)` (a fresh optimistic
135
+ write) and `db.discardFailedMutation(id)`.
114
136
 
115
137
  ## Authentication
116
138
 
@@ -164,12 +186,15 @@ Backend runs let you trigger server-side HTTP operations via an **outbox pattern
164
186
  ### `db.run()` Signature
165
187
 
166
188
  ```typescript
167
- db.run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
168
- backend: B, // Backend name from sp00ky.yml (e.g., 'api')
169
- route: R, // Route path from OpenAPI spec (e.g., '/spookify')
170
- payload: RoutePayload<S, B, R>, // Typed args for the route
171
- options?: RunOptions
172
- ): Promise<void>
189
+ // A method of the client: called as `db.run(backend, route, payload, options)`.
190
+ interface Sp00kyClient<S> {
191
+ run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
192
+ backend: B, // Backend name from sp00ky.yml (e.g., 'api')
193
+ route: R, // Route path from OpenAPI spec (e.g., '/spookify')
194
+ payload: RoutePayload<S, B, R>, // Typed args for the route
195
+ options?: RunOptions
196
+ ): Promise<void>;
197
+ }
173
198
  ```
174
199
 
175
200
  All parameters are type-safe — `backend`, `route`, and `payload` are inferred from the generated schema.
@@ -71,6 +71,12 @@ interface UpdateOptions {
71
71
  key?: 'recordId' | 'recordId_x_fields';
72
72
  /** Debounce delay in ms */
73
73
  delay?: number;
74
+ /**
75
+ * Default true. Keep the merged patch in the local `_00_pending_writes` table and
76
+ * push it as soon as the page is hidden (tab switch, reload, close), so a reload
77
+ * inside the delay does not lose it. false: memory only.
78
+ */
79
+ flushOnHide?: boolean;
74
80
  };
75
81
  }
76
82
  ```
@@ -0,0 +1,135 @@
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
+ import { queryHashInput } from '../query/hash';
10
+ import { sha256Hex } from '../utils/sha256';
11
+
12
+ const env = defaultEnv({ tables: [{ name: 'user', columns: { id: {}, email_verified: {} } }] } as any);
13
+
14
+ describe('authFlip', () => {
15
+ it('sets identity and $auth, rotates the salt for a new principal before the bucket work, writes the hint, switches, persists the user row', async () => {
16
+ const released: string[] = [];
17
+ const svc = fakeServices({
18
+ 'auth.sessionAuthId': () => 'user:abc',
19
+ 'auth.access': () => 'account',
20
+ 'local.currentBucketId': () => 'anon',
21
+ 'local.beginSwitch': () => () => void released.push('gate'),
22
+ 'local.usesSurqlSchema': () => false,
23
+ 'auth.token': () => 'jwt',
24
+ 'auth.currentUser': () => ({ id: new RecordId('user', 'abc'), email_verified: true }),
25
+ });
26
+ const state = { ...buildState(), sessionId: 'old', saltUserId: null };
27
+ const out = await runPure(authFlip(env, 'user:abc'), {
28
+ state,
29
+ handlers: { service: svc.handler, 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
30
+ });
31
+ const names = svc.names();
32
+ expect(names.slice(0, 6)).toEqual(['auth.sessionAuthId', 'auth.access', 'ssp.setSessionAuth', 'crdt.setSessionId', 'hint.write', 'local.currentBucketId']);
33
+ expect(svc.calls[3]).toEqual(['crdt.setSessionId', ['salt-1']]);
34
+ expect(svc.calls[4]).toEqual(['hint.write', ['abc']]);
35
+ expect(names).toContain('local.switchStore');
36
+ expect(released).toEqual(['gate']);
37
+ expect(out.state).toMatchObject({ userId: 'user:abc', saltUserId: 'user:abc', sessionId: 'salt-1', bucketId: 'abc' });
38
+ expect(out.state.versions.get('user:abc')).toBe(1);
39
+ });
40
+ it("re-keys active queries under the new principal's salt, so no remote id names the previous principal's view", async () => {
41
+ const svc = fakeServices({
42
+ 'auth.sessionAuthId': () => 'user:target',
43
+ 'auth.access': () => 'account',
44
+ 'local.currentBucketId': () => 'admin',
45
+ 'local.beginSwitch': () => () => undefined,
46
+ 'local.usesSurqlSchema': () => false,
47
+ 'auth.token': () => 'jwt',
48
+ 'auth.currentUser': () => null,
49
+ });
50
+ const def = { surql: 'SELECT * FROM notification', params: {} };
51
+ const before = await sha256Hex(queryHashInput(def, 'admin-salt'));
52
+ const state = R.putQuery(
53
+ buildEntry({ def: { hash: 'q', ...def, id: new RecordId('_00_query', before) }, lifecycle: { phase: 'live', remote: 'registered' } })
54
+ )({ ...buildState(), sessionId: 'admin-salt', saltUserId: 'user:admin', bucketId: 'admin' });
55
+ const out = await runPure(authFlip(env, 'user:target'), {
56
+ state,
57
+ handlers: {
58
+ service: svc.handler,
59
+ 'local.getById': () => null,
60
+ 'ssp.register': () => ({ localArray: [], timings: {} }),
61
+ 'local.query': () => [],
62
+ },
63
+ });
64
+ const id = String(out.state.queries.get('q')!.def.id.id);
65
+ expect(out.state.sessionId).toBe('salt-1');
66
+ expect(id).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
67
+ expect(id).not.toBe(before);
68
+ });
69
+ it('a new principal on the same bucket still re-keys its queries', async () => {
70
+ const svc = fakeServices({
71
+ 'auth.sessionAuthId': () => 'user:b',
72
+ 'auth.access': () => 'account',
73
+ 'local.currentBucketId': () => 'anon',
74
+ 'auth.currentUser': () => null,
75
+ });
76
+ const def = { surql: 'SELECT * FROM thing', params: {} };
77
+ const state = R.putQuery(buildEntry({ def: { hash: 'q', ...def }, lifecycle: { phase: 'live', remote: 'registered' } }))({
78
+ ...buildState(),
79
+ sessionId: 'old',
80
+ saltUserId: 'user:a',
81
+ bucketId: 'anon',
82
+ });
83
+ const out = await runPure(authFlip(env, null), {
84
+ state,
85
+ handlers: { service: svc.handler, 'local.getById': () => null, 'ssp.register': () => ({ localArray: [], timings: {} }) },
86
+ });
87
+ expect(svc.names()).not.toContain('local.switchStore');
88
+ expect(String(out.state.queries.get('q')!.def.id.id)).toBe(await sha256Hex(queryHashInput(def, 'salt-1')));
89
+ expect(out.dispatched.map((d) => d.type)).toContain('EnsureRegistered');
90
+ });
91
+ it('same principal on the same bucket: no switch, no salt rotation', async () => {
92
+ const svc = fakeServices({
93
+ 'auth.sessionAuthId': () => 'user:abc',
94
+ 'auth.access': () => 'account',
95
+ 'local.currentBucketId': () => 'abc',
96
+ 'auth.currentUser': () => null,
97
+ });
98
+ const state = { ...buildState(), sessionId: 'keep', saltUserId: 'user:abc', bucketId: 'abc' };
99
+ const out = await runPure(authFlip(env, 'user:abc'), { state, handlers: { service: svc.handler } });
100
+ expect(svc.names()).not.toContain('local.switchStore');
101
+ expect(svc.names()).not.toContain('local.beginSwitch');
102
+ expect(out.state.sessionId).toBe('keep');
103
+ });
104
+ });
105
+
106
+ describe('persistVerifiedUser', () => {
107
+ it('skips when there is no verified row, an unknown table, or a bare id; writes otherwise and logs failures', async () => {
108
+ for (const row of [null, { id: 'user:x', a: 1 }, { id: new RecordId('user', 'x') }, { id: new RecordId('nope', 'x'), a: 1 }]) {
109
+ const svc = fakeServices({ 'auth.currentUser': () => row as any });
110
+ const out = await runPure(persistVerifiedUser(env), { state: buildState(), handlers: { service: svc.handler } });
111
+ expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
112
+ }
113
+ const svc = fakeServices({ 'auth.currentUser': () => ({ id: new RecordId('user', 'x'), email_verified: true, junk: 1 }) });
114
+ const s = buildState([buildEntry({ def: { hash: 'q', tableName: 'user' } })], R.setVersions([['user:x', 4]]));
115
+ const out = await runPure(persistVerifiedUser(env), {
116
+ state: s,
117
+ handlers: {
118
+ service: svc.handler,
119
+ 'local.execute': (e: any) => expect(e.vars.content0).toEqual({ email_verified: true, _00_rv: 4 }),
120
+ 'ssp.ingest': (e: any) => expect(e.records[0].record.junk).toBeUndefined(),
121
+ },
122
+ });
123
+ expect(out.state.dirty.has('q')).toBe(true);
124
+ const failing = await runPure(persistVerifiedUser(env), {
125
+ state: s,
126
+ handlers: {
127
+ service: svc.handler,
128
+ 'local.execute': () => {
129
+ throw new Error('locked');
130
+ },
131
+ },
132
+ });
133
+ expect(failing.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
134
+ });
135
+ });
@@ -0,0 +1,77 @@
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, rebindQueries } 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 salt if
16
+ * the principal really changed, then the bucket, then the verified user row.
17
+ *
18
+ * The salt goes before the bucket because the switch re-keys every active
19
+ * query (`rebindQueries`) from the salt it finds. A query's remote id hashes
20
+ * the salt but not the principal, so re-keyed under the old salt it named the
21
+ * view the previous principal still held, and the SSP refused it as another
22
+ * identity's (409 `auth_mismatch`) until that view's TTL ran out. On whitepawn
23
+ * every impersonation start, stop and token lapse refused a tab's queries for
24
+ * the 10 minutes of that TTL, each refusal a failed sync round, so the rail
25
+ * read "Can't reach the server" the whole time.
26
+ */
27
+ export function* authFlip(env: SagaEnv, userId: string | null): Saga<void> {
28
+ const authId = (yield fx.service('auth.sessionAuthId')) as string | null;
29
+ const access = (yield fx.service('auth.access')) as string | null;
30
+ yield fx.state.update(R.setIdentity({ userId }));
31
+ yield fx.service('ssp.setSessionAuth', authId, access);
32
+ const saltUserId = (yield fx.state.read((s) => s.saltUserId)) as string | null;
33
+ const resalted = authId !== saltUserId;
34
+ if (resalted) {
35
+ const salt = (yield fx.id('salt')) as string;
36
+ yield fx.state.update(R.setIdentity({ sessionId: salt, saltUserId: authId }));
37
+ yield fx.service('crdt.setSessionId', salt);
38
+ }
39
+ const target = bucketIdForUser(userId);
40
+ yield fx.service('hint.write', target);
41
+ yield fx.state.update(R.setIdentity({ pendingBucket: target }));
42
+ const current = (yield fx.service('local.currentBucketId')) as string;
43
+ const release = current === target ? null : ((yield fx.service('local.beginSwitch')) as () => void);
44
+ yield* bucketSwitch(env, target, release);
45
+ // No switch means nothing re-keyed the queries under the new salt.
46
+ if (resalted && current === target) {
47
+ yield* rebindQueries();
48
+ yield fx.dispatch({ type: 'EnsureRegistered' });
49
+ }
50
+ yield* persistVerifiedUser(env);
51
+ }
52
+
53
+ /**
54
+ * Write the user's own row, as the server just returned it, into the local
55
+ * store so `user` queries see `email_verified` and friends without waiting
56
+ * for membership. A field refresh, not a version claim.
57
+ */
58
+ export function* persistVerifiedUser(env: SagaEnv): Saga<void> {
59
+ const row = (yield fx.service('auth.currentUser')) as Record<string, unknown> | null;
60
+ if (!row || !(row.id instanceof RecordId) || Object.keys(row).length <= 1) return;
61
+ const rid = row.id as RecordId<string>;
62
+ const table = rid.table.name;
63
+ const columns = columnsFor(env, table);
64
+ if (!columns) return;
65
+ const id = encodeRecordId(rid);
66
+ const version = ((yield fx.state.read((s) => s.versions.get(id))) as number | undefined) || 1;
67
+ const cleaned = cleanRecord(columns as never, row);
68
+ const { id: _id, ...content } = cleaned;
69
+ try {
70
+ const tx = sql.upsertBodiesTx([{ id: rid, content: { ...content, _00_rv: version } }]);
71
+ yield fx.local.execute(tx.query, tx.vars);
72
+ yield fx.ssp.ingest([{ table, op: 'UPDATE', id, record: { ...cleaned, _00_rv: version } }]);
73
+ yield fx.state.update(R.compose(R.setVersions([[id, version]]), R.markTableDirty(table)));
74
+ } catch (error) {
75
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not persist the verified user row', data: { error } });
76
+ }
77
+ }