@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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 (266) hide show
  1. package/AGENTS.md +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +373 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -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 +477 -0
  18. package/dist/types.d.ts +1857 -38
  19. package/dist/worker-edges.js +480 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  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 +203 -0
  32. package/src/boot/boot.saga.ts +146 -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 +80 -0
  36. package/src/boot/preload.saga.ts +40 -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 +195 -0
  40. package/src/client/bucket-handle.ts +352 -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 +74 -0
  46. package/src/client/services.ts +434 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -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 +167 -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 +512 -0
  62. package/src/live/index.ts +1059 -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 +228 -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 +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +178 -0
  224. package/src/sync/live.saga.ts +107 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +29 -18
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,480 @@
1
+ import { DateTime, Duration, RecordId } from "surrealdb";
2
+ import { RecordId as RecordId$1, baseFieldOfParam, cyrb53 } from "@spooky-sync/query-builder";
3
+
4
+ //#region src/query/hash.ts
5
+ /** Session-salted: two tabs of one user must not share a remote view. */
6
+ const queryHashInput = (input, sessionId) => JSON.stringify({
7
+ surql: input.surql,
8
+ params: input.params,
9
+ sessionId
10
+ });
11
+ /** Session-independent: survives reload and bucket switch. */
12
+ const viewKeyInput = (input) => JSON.stringify({
13
+ surql: input.surql,
14
+ params: input.params
15
+ });
16
+
17
+ //#endregion
18
+ //#region src/utils/sha256.ts
19
+ /**
20
+ * Lowercase hex SHA-256 of a UTF-8 string, through WebCrypto.
21
+ *
22
+ * Pure and dependency free so it can live in `@spooky-sync/core/pure` and run
23
+ * in a service worker, a dedicated worker or Node (`globalThis.crypto` is
24
+ * there on Node 19+). Query keys are `sha256Hex(JSON.stringify(..))`, see
25
+ * `query/hash.ts`.
26
+ */
27
+ async function sha256Hex(input) {
28
+ const bytes = new TextEncoder().encode(input);
29
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
30
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
31
+ }
32
+
33
+ //#endregion
34
+ //#region src/utils/surql.ts
35
+ const surql = {
36
+ seal(query, options) {
37
+ if (typeof query === "string") return `${query};`;
38
+ const txQuery = query;
39
+ const idx = options?.resultIndex ?? txQuery.statementCount - 1;
40
+ return {
41
+ sql: `${txQuery.sql};`,
42
+ extract(results) {
43
+ return results[idx + 1];
44
+ }
45
+ };
46
+ },
47
+ tx(queries) {
48
+ return {
49
+ __brand: "TxQuery",
50
+ sql: `BEGIN TRANSACTION;\n${queries.join(";")};\nCOMMIT TRANSACTION`,
51
+ statementCount: queries.length
52
+ };
53
+ },
54
+ selectById(idVar, returnValues) {
55
+ return `SELECT ${returnValues.join(",")} FROM ONLY $${idVar}`;
56
+ },
57
+ selectByFieldsAnd(table, whereVar, returnValues) {
58
+ return `SELECT ${returnValues.map((rv) => typeof rv === "string" ? rv : `${rv.field} as ${rv.alias}`).join(",")} FROM ${table} WHERE ${whereVar.map((wv) => typeof wv === "string" ? `${wv} = $${wv}` : `${wv.field} = $${wv.variable}`).join(" AND ")}`;
59
+ },
60
+ create(idVar, dataVar) {
61
+ return `CREATE ONLY $${idVar} CONTENT $${dataVar}`;
62
+ },
63
+ createSet(idVar, keyDataVars) {
64
+ return `CREATE ONLY $${idVar} SET ${keyDataVars.map((keyDataVar) => typeof keyDataVar === "string" ? `${keyDataVar} = $${keyDataVar}` : "statement" in keyDataVar ? keyDataVar.statement : `${keyDataVar.key} = $${keyDataVar.variable}`).join(", ")}`;
65
+ },
66
+ upsert(idVar, dataVar) {
67
+ return `UPSERT ONLY $${idVar} REPLACE $${dataVar}`;
68
+ },
69
+ upsertMerge(idVar, dataVar) {
70
+ return `UPSERT ONLY $${idVar} MERGE $${dataVar}`;
71
+ },
72
+ updateMerge(idVar, dataVar) {
73
+ return `UPDATE ONLY $${idVar} MERGE $${dataVar}`;
74
+ },
75
+ updateSet(idVar, keyDataVar) {
76
+ return `UPDATE $${idVar} SET ${keyDataVar.map((kdv) => typeof kdv === "string" ? `${kdv} = $${kdv}` : "statement" in kdv ? kdv.statement : `${kdv.key} = $${kdv.variable}`).join(", ")}`;
77
+ },
78
+ delete(idVar) {
79
+ return `DELETE $${idVar}`;
80
+ },
81
+ let(name, query) {
82
+ return `LET $${name} = (${query})`;
83
+ },
84
+ createMutation(t, mutationIdVar, recordIdVar, dataVar, beforeRecordVar) {
85
+ switch (t) {
86
+ case "create": return dataVar ? `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}, data = $${dataVar}` : `CREATE ONLY $${mutationIdVar} SET mutationType = 'create', recordId = $${recordIdVar}`;
87
+ case "update": {
88
+ let stmt = `CREATE ONLY $${mutationIdVar} SET mutationType = 'update', recordId = $${recordIdVar}, data = $${dataVar}`;
89
+ if (beforeRecordVar) stmt += `, beforeRecord = $${beforeRecordVar}`;
90
+ return stmt;
91
+ }
92
+ case "delete": return `CREATE ONLY $${mutationIdVar} SET mutationType = 'delete', recordId = $${recordIdVar}`;
93
+ }
94
+ },
95
+ returnObject(entries) {
96
+ return `RETURN {${entries.map(({ key, variable }) => `${key}: $${variable}`).join(",")}}`;
97
+ }
98
+ };
99
+
100
+ //#endregion
101
+ //#region src/modules/ref-tables.ts
102
+ /**
103
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
104
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
105
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
106
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
107
+ */
108
+ const ANON_USER_ID = "anon";
109
+ /** The one `_00_list_ref` table every signed-out session shares. */
110
+ const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
111
+ /**
112
+ * Default ref-storage mode for this client build. Mirrors the SSP's
113
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
114
+ * box.
115
+ */
116
+ const DEFAULT_REF_MODE = "dedicated";
117
+ /**
118
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
119
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
120
+ * the id is missing the `user:` prefix or contains characters that
121
+ * aren't valid in a SurrealDB table identifier — the server-side
122
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
123
+ *
124
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
125
+ * objects (which only stringify cleanly via `.toString()`), since
126
+ * `AuthService` passes the record-id object as-is to its subscribers.
127
+ */
128
+ function sanitizeUserId(userId) {
129
+ if (userId === null || userId === void 0) return null;
130
+ const asString = typeof userId === "string" ? userId : typeof userId.toString === "function" ? userId.toString() : null;
131
+ if (!asString) return null;
132
+ const raw = asString.startsWith("user:") ? asString.slice(5) : asString;
133
+ if (raw.length === 0) return null;
134
+ if (!/^[A-Za-z0-9_]+$/.test(raw)) return null;
135
+ return raw;
136
+ }
137
+ /**
138
+ * Resolve the LOCAL storage bucket id for a user. Every user gets their own
139
+ * IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
140
+ * query state, and the mutation outbox never leak across accounts on a shared
141
+ * device. Signed-out sessions share the `anon` bucket.
142
+ *
143
+ * An id that fails sanitization still gets a DETERMINISTIC per-user bucket
144
+ * (cyrb53 hex of the raw id) — falling back to `anon` here would put an
145
+ * authenticated user in the shared bucket and recreate the cross-user leak.
146
+ */
147
+ function bucketIdForUser(userId) {
148
+ if (userId === null || userId === void 0 || userId === ANON_USER_ID) return ANON_USER_ID;
149
+ const uid = sanitizeUserId(userId);
150
+ if (uid) return uid;
151
+ return `u${cyrb53(String(userId)).toString(16)}`;
152
+ }
153
+ /**
154
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
155
+ * back to the global `_00_list_ref` when sanitization fails or in
156
+ * single mode.
157
+ */
158
+ function listRefTableFor(mode, userId) {
159
+ if (userId === ANON_USER_ID) return ANON_LIST_REF_TABLE;
160
+ if (mode === "single") return "_00_list_ref";
161
+ const uid = sanitizeUserId(userId);
162
+ return uid ? `_00_list_ref_user_${uid}` : "_00_list_ref";
163
+ }
164
+
165
+ //#endregion
166
+ //#region src/query/sql.ts
167
+ /** Every SurrealQL statement the query side sends, as pure builders. */
168
+ const VIEW_TABLE = "_00_view";
169
+ const LEGACY_VIEW_TABLE = "_00_window";
170
+ const viewRecordId = (viewKey) => new RecordId(VIEW_TABLE, viewKey);
171
+ /**
172
+ * The durable `_00_view` row: the confirmed membership plus the subquery
173
+ * children, which the GC must keep and a cold scan may paint as much as the
174
+ * members.
175
+ */
176
+ const viewRow = (ids, children, confirmed, now) => ({
177
+ ids,
178
+ children,
179
+ confirmed,
180
+ updatedAt: now
181
+ });
182
+ /** Every `_00_view` row: the durable view index and the GC read it. */
183
+ const readViewRows = () => `SELECT * FROM ${VIEW_TABLE}`;
184
+ /** Copy every legacy `_00_window` row into `_00_view` once. */
185
+ const readLegacyViewRows = () => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
186
+ const countViewRows = () => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
187
+ const listRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
188
+ const subqueryListRefSelect = (table) => `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
189
+ const queryRowCountSelect = () => "SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in";
190
+ const listRefBatchSelect = (table) => `SELECT in, out, version, parent FROM $ins->${table}`;
191
+ const queryRowCountBatchSelect = () => "SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins";
192
+ /** The single-query read: edges, meta, subquery children in one request. */
193
+ const singleSnapshotSelect = (table) => `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
194
+ /** The many-query read: all edges + all metas in one request. */
195
+ const batchSnapshotSelect = (table) => `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
196
+ /**
197
+ * The session's one LIVE subscription on its `_00_list_ref` table.
198
+ *
199
+ * `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
200
+ * notification so the body arrives with the doorbell.
201
+ *
202
+ * On the anon table it is filtered to this client's own edges. Every
203
+ * signed-out session shares that table, so unfiltered each one received (and
204
+ * the server fetched a body for) every anonymous edge change of every other
205
+ * session, and the client then dropped all but its own. An edge carries the
206
+ * `session::id()` of whichever socket last registered its view, which is a
207
+ * socket of this client but not necessarily the current one: a view stays
208
+ * registered across a reconnect, and a reload keeps its salt and its views,
209
+ * so their older edges still name an earlier socket. `sessionIds` are those
210
+ * earlier sockets. The current one is matched by `session::id()` itself,
211
+ * which SurrealDB evaluates in the subscriber's own session, so the filter
212
+ * holds before the client has learned its new id.
213
+ *
214
+ * The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
215
+ * and without it nothing matches. Per-user tables need no filter.
216
+ */
217
+ function listRefLive(table, opts) {
218
+ const join = opts.inlineBodies ? " FETCH out" : "";
219
+ if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
220
+ return {
221
+ sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
222
+ vars: { sids: [...opts.sessionIds] }
223
+ };
224
+ }
225
+ /** Register + read back edges/meta/children in ONE request. */
226
+ const registerSelect = (table) => `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
227
+ const registerVars = (payload) => ({
228
+ config: payload,
229
+ in: payload.id
230
+ });
231
+ /** One heartbeat statement per view id; result index i answers id i. */
232
+ function heartbeatBatch(ids) {
233
+ const vars = {};
234
+ return {
235
+ sql: ids.map((id, i) => {
236
+ vars[`id${i}`] = id;
237
+ return `fn::query::heartbeat($id${i})`;
238
+ }).join(";\n"),
239
+ vars
240
+ };
241
+ }
242
+ /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
243
+ const heartbeatRowGone = (result) => Array.isArray(result) && result.length === 0;
244
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
245
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
246
+ /**
247
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
248
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
249
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
250
+ * interpolated. `null` when there is nothing the server could act on: with no
251
+ * session ids it could not tell this page's entries from anyone else's.
252
+ */
253
+ function leaveViewsBeacon(ids, sessionIds) {
254
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
255
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
256
+ if (list.length === 0 || sids.length === 0) return null;
257
+ const sidList = sids.map((sid) => `'${sid}'`).join(", ");
258
+ return `FOR $id IN [${list.join(", ")}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
259
+ }
260
+ const bodySelect = () => "SELECT * FROM $ids";
261
+ /** Local transaction that MERGEs many fetched bodies. */
262
+ function upsertBodiesTx(records) {
263
+ const vars = {};
264
+ const stmts = records.map((r, i) => {
265
+ vars[`id${i}`] = r.id;
266
+ vars[`content${i}`] = r.content;
267
+ return surql.upsertMerge(`id${i}`, `content${i}`);
268
+ });
269
+ return {
270
+ query: surql.seal(surql.tx(stmts)),
271
+ vars
272
+ };
273
+ }
274
+
275
+ //#endregion
276
+ //#region src/utils/parser.ts
277
+ /**
278
+ * Parse a QUERY's params. Unlike a record's fields, a param name is not always a
279
+ * column: an `_or` branch binds under a synthetic `white__or0` (see
280
+ * `orParamName` in @spooky-sync/query-builder), and the surql that references it
281
+ * is already written. So resolve the column through the synthetic name, and keep
282
+ * a param we cannot type rather than dropping it.
283
+ *
284
+ * Dropping was the bug: `parseParams` kept only column-named params, so every
285
+ * `_or` query registered with `$or0`/`$or1` unbound and matched no rows at all,
286
+ * silently (rowCount 0, errorCount 0).
287
+ */
288
+ function parseQueryParams(tableSchema, params) {
289
+ const parsedParams = {};
290
+ for (const [key, value] of Object.entries(params)) {
291
+ if (value === void 0) continue;
292
+ const column = tableSchema[key] ?? tableSchema[baseFieldOfParam(key)];
293
+ parsedParams[key] = column ? parseValue(key, column, value) : value;
294
+ }
295
+ return parsedParams;
296
+ }
297
+ function parseValue(name, column, value) {
298
+ if (column.recordId) {
299
+ if (value instanceof RecordId$1) return value;
300
+ if (typeof value === "string") return parseRecordIdString(value);
301
+ throw new Error(`Invalid value for ${name}: ${value}`);
302
+ }
303
+ if (column.dateTime) {
304
+ if (value instanceof Date) return value;
305
+ if (value instanceof DateTime) return value.toDate();
306
+ if (typeof value === "number" || typeof value === "string") return new Date(value);
307
+ throw new Error(`Invalid value for ${name}: ${value}`);
308
+ }
309
+ return value;
310
+ }
311
+
312
+ //#endregion
313
+ //#region src/utils/index.ts
314
+ const encodeRecordId = (recordId) => {
315
+ return `${recordId.table.name}:${recordId.id}`;
316
+ };
317
+ const parseRecordIdString = (id) => {
318
+ const [table, ...idParts] = id.split(":");
319
+ return new RecordId(table, idParts.join(":"));
320
+ };
321
+ /**
322
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
323
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
324
+ * than `randomUUID`, which a page served over plain http does not have.
325
+ */
326
+ function mintOwnerSecret() {
327
+ const bytes = new Uint8Array(32);
328
+ globalThis.crypto.getRandomValues(bytes);
329
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
330
+ }
331
+ /**
332
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
333
+ * releases expose it under `milliseconds` (current) or the older private
334
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
335
+ */
336
+ function durationMillis(duration) {
337
+ const d = duration;
338
+ return Number(d.milliseconds || d._milliseconds || 0);
339
+ }
340
+ /**
341
+ * Parse duration string or Duration object to milliseconds
342
+ */
343
+ function parseDuration(duration) {
344
+ if (duration instanceof Duration) {
345
+ const ms = durationMillis(duration);
346
+ if (ms) return ms;
347
+ const str = duration.toString();
348
+ if (str !== "[object Object]") return parseDuration(str);
349
+ return 6e5;
350
+ }
351
+ if (typeof duration === "bigint") return Number(duration);
352
+ if (typeof duration !== "string") return 6e5;
353
+ const match = duration.match(/^(\d+)([smh])$/);
354
+ if (!match) return 6e5;
355
+ const val = Number.parseInt(match[1], 10);
356
+ switch (match[2]) {
357
+ case "s": return val * 1e3;
358
+ case "h": return val * 36e5;
359
+ default: return val * 6e4;
360
+ }
361
+ }
362
+ /**
363
+ * Reject after `timeoutMs` if `promise` hasn't settled.
364
+ *
365
+ * Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
366
+ * parks each call in a pending map and only rejects it when the socket reports
367
+ * a close. On a half-open socket (peer gone, no `close` event, `readyState`
368
+ * still OPEN) the call never settles at all, which wedges anything serialized
369
+ * behind it.
370
+ *
371
+ * `message` MUST contain "timed out" so `classifySyncError` treats the
372
+ * rejection as `network` — that's what makes the sync queues retry the
373
+ * operation instead of rolling the mutation back as an application error.
374
+ *
375
+ * Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
376
+ * is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
377
+ * settlement is absorbed, so attach no expectations to it after a timeout.
378
+ */
379
+ function withTimeout(promise, timeoutMs, message) {
380
+ if (!(timeoutMs > 0)) return promise;
381
+ return new Promise((resolve, reject) => {
382
+ const timer = setTimeout(() => reject(typeof message === "function" ? message() : new Error(message)), timeoutMs);
383
+ promise.then((value) => {
384
+ clearTimeout(timer);
385
+ resolve(value);
386
+ }, (err) => {
387
+ clearTimeout(timer);
388
+ reject(err);
389
+ });
390
+ });
391
+ }
392
+
393
+ //#endregion
394
+ //#region src/query/membership.ts
395
+ function metaFromRow(row) {
396
+ if (!row || typeof row !== "object") return {
397
+ present: false,
398
+ rowCount: null,
399
+ state: null
400
+ };
401
+ const state = row.state === "materializing" || row.state === "ready" ? row.state : null;
402
+ return {
403
+ present: true,
404
+ rowCount: typeof row.rowCount === "number" ? row.rowCount : null,
405
+ state
406
+ };
407
+ }
408
+ /** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
409
+ function dedupeRecordVersions(pairs) {
410
+ if (pairs.length < 2) return pairs;
411
+ const best = /* @__PURE__ */ new Map();
412
+ for (const [id, version] of pairs) {
413
+ const prev = best.get(id);
414
+ if (prev === void 0 || version > prev) best.set(id, version);
415
+ }
416
+ if (best.size === pairs.length) return pairs;
417
+ return Array.from(best.entries());
418
+ }
419
+ /**
420
+ * A statement that did not answer (an ERR, or no result at all). Kept apart
421
+ * from a `null` result, which is the server saying "no such row": reading a
422
+ * failed statement as a missing row turned every timeout under load into a
423
+ * lost view and re-registered it, over and over (whitepawn, 2026-09-16).
424
+ */
425
+ const FAILED = Symbol("statement failed");
426
+ const toPairs = (rows) => dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
427
+ /**
428
+ * Fold the single-query statement batch (edges, meta, children) into a
429
+ * snapshot. `null` when any of the three did not answer: half an answer is
430
+ * not one, and a missing meta would read as a lost view.
431
+ */
432
+ function snapshotFromSingle(items, metaRow, children) {
433
+ if (!Array.isArray(items) || metaRow === FAILED || children === FAILED) return null;
434
+ return {
435
+ primary: toPairs(items),
436
+ subquery: toPairs(children),
437
+ meta: metaFromRow(metaRow)
438
+ };
439
+ }
440
+
441
+ //#endregion
442
+ //#region src/utils/edges.ts
443
+ /**
444
+ * Reading `_00_list_ref` edges as a LIVE notification delivers them. Pure, so
445
+ * both the full client and the wasm-free live feed (`@spooky-sync/core/live`)
446
+ * decode the same wire shape the same way.
447
+ */
448
+ /** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
449
+ function hashOfEdge(value) {
450
+ const inId = value?.in;
451
+ if (!inId) return null;
452
+ const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, "");
453
+ return str.length > 0 ? str : null;
454
+ }
455
+ /**
456
+ * The row an edge notification carries, when the subscription joined it on.
457
+ *
458
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
459
+ * land, so this returns null and the caller falls back to fetching the body.
460
+ * It also returns null for a row the session may not read (the join yields
461
+ * `out: null`) and for an edge whose target has been deleted.
462
+ */
463
+ function rowOfEdge(value) {
464
+ const edge = value;
465
+ const out = edge?.out;
466
+ if (!out || typeof out !== "object" || Array.isArray(out) || out instanceof RecordId) return null;
467
+ const rid = out.id;
468
+ if (!(rid instanceof RecordId)) return null;
469
+ const id = encodeRecordId(rid);
470
+ const version = typeof edge?.version === "number" ? edge.version : null;
471
+ if (version === null) return null;
472
+ return {
473
+ id,
474
+ version,
475
+ record: out
476
+ };
477
+ }
478
+
479
+ //#endregion
480
+ export { singleSnapshotSelect as A, sanitizeUserId as B, listRefSelect as C, readViewRows as D, readLegacyViewRows as E, ANON_LIST_REF_TABLE as F, sha256Hex as H, ANON_USER_ID as I, DEFAULT_REF_MODE as L, upsertBodiesTx as M, viewRecordId as N, registerSelect as O, viewRow as P, bucketIdForUser as R, listRefLive as S, queryRowCountSelect as T, queryHashInput as U, surql as V, viewKeyInput as W, countViewRows as _, metaFromRow as a, leaveViewsBeacon as b, mintOwnerSecret as c, withTimeout as d, parseQueryParams as f, bodySelect as g, batchSnapshotSelect as h, dedupeRecordVersions as i, subqueryListRefSelect as j, registerVars as k, parseDuration as l, VIEW_TABLE as m, rowOfEdge as n, snapshotFromSingle as o, LEGACY_VIEW_TABLE as p, FAILED as r, encodeRecordId as s, hashOfEdge as t, parseRecordIdString as u, heartbeatBatch as v, queryRowCountBatchSelect as w, listRefBatchSelect as x, heartbeatRowGone as y, listRefTableFor as z };
@@ -0,0 +1,60 @@
1
+ //#region src/modules/auth/impersonation.ts
2
+ /**
3
+ * Admin impersonation, client side.
4
+ *
5
+ * The server does the enforcing (see `impersonation_remote.surql` in the CLI):
6
+ * an admin calls `fn::_00_impersonate::start`, receives a token for the
7
+ * `_00_impersonate` access method, and authenticates with it, so `$auth.id` is
8
+ * the target user. Everything here is state and timers around that:
9
+ *
10
+ * - the admin's own token is kept under {@link ORIGIN_TOKEN_KEY} so Stop (and a
11
+ * reload in between) can return to it;
12
+ * - the impersonation state is derived from the token's claims, never stored
13
+ * separately, so it cannot disagree with the session actually in use;
14
+ * - the token is re-signed before it expires, and the session is polled so a
15
+ * stop or revocation made elsewhere reaches an open WebSocket quickly.
16
+ */
17
+ /** Access method impersonation tokens are issued for. */
18
+ const IMPERSONATION_ACCESS = "_00_impersonate";
19
+ /**
20
+ * Read the claims of a SurrealDB record-access JWT WITHOUT verifying it. The
21
+ * server still enforces the token on every request; this is only so the client
22
+ * can act on what it already holds before a round trip completes.
23
+ *
24
+ * Returns nulls on any malformed input.
25
+ */
26
+ function decodeTokenClaims(token) {
27
+ const none = {
28
+ access: null,
29
+ userId: null,
30
+ exp: null,
31
+ impersonation: null
32
+ };
33
+ try {
34
+ const payload = token.split(".")[1];
35
+ if (!payload) return none;
36
+ let b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
37
+ b64 += "=".repeat((4 - b64.length % 4) % 4);
38
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
39
+ const claims = JSON.parse(json);
40
+ const ac = claims.AC ?? claims.ac;
41
+ const id = claims.ID ?? claims.id;
42
+ const access = typeof ac === "string" ? ac : null;
43
+ const session = claims.spky_imp;
44
+ const admin = claims.spky_admin;
45
+ return {
46
+ access,
47
+ userId: typeof id === "string" ? id : null,
48
+ exp: typeof claims.exp === "number" ? claims.exp : null,
49
+ impersonation: access === IMPERSONATION_ACCESS && typeof session === "string" && typeof admin === "string" ? {
50
+ session,
51
+ admin
52
+ } : null
53
+ };
54
+ } catch {
55
+ return none;
56
+ }
57
+ }
58
+
59
+ //#endregion
60
+ export { decodeTokenClaims as n, IMPERSONATION_ACCESS as t };
@@ -0,0 +1,48 @@
1
+ //#region src/modules/ref-tables.d.ts
2
+ type RefMode = 'single' | 'dedicated';
3
+ /**
4
+ * Sentinel user id for unauthenticated clients when anonymous live queries are
5
+ * enabled. Mirrors `ssp_protocol::ANON_AUTH_ID`. It carries no `user:` prefix
6
+ * so it can never collide with a real user id (those arrive as `user:<id>`);
7
+ * both sides resolve it to the shared `_00_list_ref_anon` table.
8
+ */
9
+ declare const ANON_USER_ID = "anon";
10
+ /** The one `_00_list_ref` table every signed-out session shares. */
11
+ declare const ANON_LIST_REF_TABLE = "_00_list_ref_anon";
12
+ /**
13
+ * Default ref-storage mode for this client build. Mirrors the SSP's
14
+ * default (`RefMode::Dedicated`) so cross-session sync works out of the
15
+ * box.
16
+ */
17
+ declare const DEFAULT_REF_MODE: RefMode;
18
+ /**
19
+ * Sanitize a user record id (e.g. `"user:abc"`) into the segment that
20
+ * goes into a dedicated table name (e.g. `"abc"`). Returns `null` if
21
+ * the id is missing the `user:` prefix or contains characters that
22
+ * aren't valid in a SurrealDB table identifier — the server-side
23
+ * `ssp_protocol::sanitize_user_id` uses the same predicate.
24
+ *
25
+ * Accepts both string ids (`"user:abc"`) and SurrealDB `RecordId`
26
+ * objects (which only stringify cleanly via `.toString()`), since
27
+ * `AuthService` passes the record-id object as-is to its subscribers.
28
+ */
29
+ declare function sanitizeUserId(userId: unknown): string | null;
30
+ /**
31
+ * Resolve the LOCAL storage bucket id for a user. Every user gets their own
32
+ * IndexedDB-backed local store (`indxdb://sp00ky-<bucketId>`) so cached rows,
33
+ * query state, and the mutation outbox never leak across accounts on a shared
34
+ * device. Signed-out sessions share the `anon` bucket.
35
+ *
36
+ * An id that fails sanitization still gets a DETERMINISTIC per-user bucket
37
+ * (cyrb53 hex of the raw id) — falling back to `anon` here would put an
38
+ * authenticated user in the shared bucket and recreate the cross-user leak.
39
+ */
40
+ declare function bucketIdForUser(userId: unknown): string;
41
+ /**
42
+ * Returns the `_00_list_ref` table name for `(mode, userId)`. Falls
43
+ * back to the global `_00_list_ref` when sanitization fails or in
44
+ * single mode.
45
+ */
46
+ declare function listRefTableFor(mode: RefMode, userId: unknown): string;
47
+ //#endregion
48
+ export { bucketIdForUser as a, RefMode as i, ANON_USER_ID as n, listRefTableFor as o, DEFAULT_REF_MODE as r, sanitizeUserId as s, ANON_LIST_REF_TABLE as t };