@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,527 @@
1
+ import { createServer, type Server } from 'node:http';
2
+ import { Socket, type AddressInfo } from 'node:net';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
7
+ import { RecordId, Surreal } from 'surrealdb';
8
+ import { liveEndpoint } from '../live/index';
9
+ import {
10
+ heartbeatBatch,
11
+ heartbeatRowGone,
12
+ leaveViewsBeacon,
13
+ queryRowCountBatchSelect,
14
+ queryRowCountSelect,
15
+ } from './sql';
16
+
17
+ /**
18
+ * Every signed-out visitor shares the one `anon` identity, so `_00_query`
19
+ * tells their views apart by session: an anonymous row answers only to the
20
+ * sessions in its `subscribers`. A second visitor must not be able to list,
21
+ * read, rewrite, plant or delete the first one's view, while the first keeps
22
+ * its heartbeat and its registration read-back, also after a reconnect.
23
+ *
24
+ * Runs the shipped `_00_query` block and `fn::query::*` functions from
25
+ * `apps/cli/src` against a real SurrealDB, because the guarantee is the
26
+ * engine's evaluation of the table and field permissions. Boot one first, e.g.:
27
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
28
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
29
+ *
30
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
31
+ *
32
+ * The registration tests also run the shipped `fn::query::register`, whose
33
+ * `http::post` must reach a stand-in SSP this test serves. SurrealDB reaches it
34
+ * at SURREAL_IT_CALLBACK_HOST (default `host.docker.internal`, the host as seen
35
+ * from Docker Desktop); those tests skip when it cannot.
36
+ */
37
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
38
+ const callbackHost = process.env.SURREAL_IT_CALLBACK_HOST ?? 'host.docker.internal';
39
+ const NS = 'it';
40
+
41
+ const cliSource = (file: string) =>
42
+ readFileSync(fileURLToPath(new URL(`../../../../apps/cli/src/${file}`, import.meta.url)), 'utf8');
43
+
44
+ /**
45
+ * The SET clause ssp-node puts first in every registration write, read out of
46
+ * `packages/ssp-node/src/node.rs` so this test runs the shipped text.
47
+ */
48
+ function ownerGuard(): string {
49
+ const src = readFileSync(
50
+ fileURLToPath(new URL('../../../ssp-node/src/node.rs', import.meta.url)),
51
+ 'utf8'
52
+ );
53
+ const m = /pub const OWNER_GUARD: &str = "((?:[^"\\]|\\.)*)";/s.exec(src);
54
+ if (!m) throw new Error('OWNER_GUARD not found in ssp-node/src/node.rs');
55
+ // A Rust `\` at a line end drops the newline and the next line's indent.
56
+ return m[1].replace(/\\\n\s*/g, '');
57
+ }
58
+
59
+ const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
60
+
61
+ /** Just the `_00_query` block: the rest of the file needs tables and modules this test does not. */
62
+ function queryTableSchema(): string {
63
+ const meta = cliSource('meta_tables_remote.surql');
64
+ const start = meta.indexOf('DEFINE TABLE OVERWRITE _00_query SCHEMALESS');
65
+ const end = meta.indexOf('-- SPOOKY LIST REF');
66
+ if (start < 0 || end < start)
67
+ throw new Error('_00_query block not found in meta_tables_remote.surql');
68
+ return meta.slice(start, end);
69
+ }
70
+
71
+ const ACCESS = `
72
+ DEFINE TABLE OVERWRITE user SCHEMALESS PERMISSIONS FOR select WHERE id = $auth.id;
73
+ DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
74
+ SIGNUP (CREATE user SET name = $name)
75
+ SIGNIN (SELECT * FROM user WHERE name = $name)
76
+ DURATION FOR TOKEN 1h, FOR SESSION 1h;
77
+ `;
78
+
79
+ /** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
80
+ const SSP_REGISTER = `
81
+ UPSERT $id SET clientId = $sid, auth_id = $owner, surql = 'SELECT * FROM item',
82
+ params = { auth: { id: $authParam }, access: '' }, ttl = 1h, lastActiveAt = time::now(),
83
+ rowCount = 3, state = 'ready',
84
+ subscribers = array::append(
85
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
86
+ { id: $sid, seenAt: time::now() })`;
87
+
88
+ /**
89
+ * What the SSP writes, as root, when a session joins an anonymous view that
90
+ * already exists (`join_registered_view`), with the shipped ownership guard.
91
+ */
92
+ const SSP_JOIN = () => `
93
+ UPSERT type::record($id) SET ${ownerGuard()}, clientId = $sid, auth_id = auth_id ?? 'anon',
94
+ surql = surql ?? 'SELECT * FROM item', params = params ?? { auth: { id: '' }, access: '' },
95
+ ttl = ttl ?? 1h, lastActiveAt = time::now(), rowCount = rowCount ?? 3, state = state ?? 'ready',
96
+ subscribers = array::append(
97
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
98
+ { id: $sid, seenAt: time::now() })`;
99
+
100
+ /** A `_00_query:<sha256>` id, the shape the client mints (and the pagehide beacon accepts). */
101
+ const viewId = () =>
102
+ new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
103
+
104
+ describe('anonymous _00_query isolation (real SurrealDB)', () => {
105
+ let root: Surreal;
106
+ let database = '';
107
+ let reachable = false;
108
+ const open: Surreal[] = [];
109
+
110
+ // The stand-in SSP: records what `fn::query::register` posts and answers it
111
+ // as ssp-node's warm join does (409 `owner_mismatch` when the guard throws).
112
+ const posted: Array<Record<string, unknown>> = [];
113
+ let ssp: Server | null = null;
114
+ let sspUrl = '';
115
+ let sspReachable: boolean | null = null;
116
+
117
+ // A plain TCP probe: the SDK never rejects a connect to a refused port.
118
+ beforeAll(async () => {
119
+ const url = new URL(endpoint);
120
+ reachable = await new Promise<boolean>((resolve) => {
121
+ const socket = new Socket();
122
+ const done = (ok: boolean) => {
123
+ socket.destroy();
124
+ resolve(ok);
125
+ };
126
+ socket.setTimeout(1_000, () => done(false));
127
+ socket.once('error', () => done(false));
128
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
129
+ });
130
+ ssp = createServer(async (req, res) => {
131
+ let text = '';
132
+ for await (const chunk of req) text += chunk;
133
+ const body = JSON.parse(text) as Record<string, unknown>;
134
+ posted.push(body);
135
+ if (body.probe) return void res.writeHead(200).end('null');
136
+ try {
137
+ await root.query(SSP_JOIN(), {
138
+ id: body.id,
139
+ sid: body.clientId,
140
+ ownerHash: body.ownerHash ?? '',
141
+ });
142
+ res.writeHead(200, { 'content-type': 'application/json' }).end('null');
143
+ } catch (error) {
144
+ const refused = String(error).includes('owner_mismatch');
145
+ res
146
+ .writeHead(refused ? 409 : 500, { 'content-type': 'application/json' })
147
+ .end(
148
+ JSON.stringify({
149
+ code: refused ? 'owner_mismatch' : 'db_error',
150
+ message: String(error),
151
+ })
152
+ );
153
+ }
154
+ });
155
+ await new Promise<void>((resolve) => ssp!.listen(0, '0.0.0.0', resolve));
156
+ sspUrl = `http://${callbackHost}:${(ssp.address() as AddressInfo).port}`;
157
+ });
158
+
159
+ afterAll(async () => {
160
+ await new Promise((resolve) => ssp?.close(resolve));
161
+ });
162
+
163
+ /** Load the shipped `fn::query::register` against the stand-in; false when SurrealDB cannot reach it. */
164
+ async function registerFunctions(): Promise<boolean> {
165
+ if (sspReachable === null) {
166
+ sspReachable = await root
167
+ .query('RETURN http::post($url, { probe: true })', { url: `${sspUrl}/probe` })
168
+ .then(
169
+ () => true,
170
+ () => false
171
+ );
172
+ }
173
+ if (!sspReachable) return false;
174
+ await root.query(
175
+ cliSource('functions_remote_singlenode.surql')
176
+ .replaceAll('{{ENDPOINT}}', sspUrl)
177
+ .replaceAll('{{SECRET}}', 'it-secret')
178
+ );
179
+ return true;
180
+ }
181
+
182
+ beforeEach(async () => {
183
+ if (!reachable) return;
184
+ database = `anon_query_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
185
+ root = new Surreal();
186
+ await root.connect(endpoint, { reconnect: false });
187
+ await root.signin({ username: 'root', password: 'root' });
188
+ await root.use({ namespace: NS, database });
189
+ await root.query(ACCESS);
190
+ await root.query(queryTableSchema());
191
+ await root.query(cliSource('functions_remote.surql'));
192
+ });
193
+
194
+ afterEach(async () => {
195
+ if (!reachable) return;
196
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
197
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
198
+ await root.close();
199
+ });
200
+
201
+ /** A signed-out socket and its `session::id()`. */
202
+ async function guest(): Promise<[Surreal, string]> {
203
+ const db = new Surreal();
204
+ open.push(db);
205
+ await db.connect(endpoint, { reconnect: false });
206
+ await db.use({ namespace: NS, database });
207
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
208
+ return [db, sid];
209
+ }
210
+
211
+ /** A signed-in socket, its `session::id()`, `$auth.id` and access token. */
212
+ async function member(name: string): Promise<[Surreal, string, string, string]> {
213
+ const db = new Surreal();
214
+ open.push(db);
215
+ await db.connect(endpoint, { reconnect: false });
216
+ await db.use({ namespace: NS, database });
217
+ const tokens = (await db.signup({
218
+ namespace: NS,
219
+ database,
220
+ access: 'account',
221
+ variables: { name },
222
+ })) as string | { access: string };
223
+ const [sid, authId] = (await db.query(
224
+ 'RETURN <string>session::id(); RETURN <string>$auth.id'
225
+ )) as [string, string];
226
+ return [db, sid, authId, typeof tokens === 'string' ? tokens : tokens.access];
227
+ }
228
+
229
+ /** `beaconSql`'s request: a fresh HTTP session, Bearer token only when signed in. */
230
+ async function beacon(sql: string, token?: string): Promise<void> {
231
+ const url = new URL(endpoint);
232
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
233
+ url.pathname = url.pathname.replace(/\/rpc\/?$/, '') + '/sql';
234
+ const headers: Record<string, string> = {
235
+ Accept: 'application/json',
236
+ 'Content-Type': 'text/plain',
237
+ 'surreal-ns': NS,
238
+ 'surreal-db': database,
239
+ };
240
+ if (token) headers.Authorization = `Bearer ${token}`;
241
+ const res = await fetch(url, { method: 'POST', headers, body: sql });
242
+ expect(res.ok).toBe(true);
243
+ }
244
+
245
+ const register = (id: RecordId<string>, sid: string, owner = 'anon') =>
246
+ root.query(SSP_REGISTER, { id, sid, owner, authParam: owner === 'anon' ? '' : owner });
247
+ const row = async (id: RecordId<string>) =>
248
+ ((await root.query('SELECT * FROM ONLY $id', { id })) as [Record<string, any> | undefined])[0];
249
+ const count = async () =>
250
+ ((await root.query('SELECT VALUE id FROM _00_query')) as [unknown[]])[0].length;
251
+
252
+ /** The client's register read-back, as `registerSelect` sends it after `fn::query::register`. */
253
+ const readBack = async (db: Surreal, id: RecordId<string>) =>
254
+ ((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
255
+ /** The client's lifecycle tick: did the heartbeat find the row? */
256
+ const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
257
+ const beat = heartbeatBatch([id]);
258
+ const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
259
+ return !heartbeatRowGone(result);
260
+ };
261
+
262
+ it("a second guest cannot list, read, rewrite, release or delete the first guest's view", async (ctx) => {
263
+ if (!reachable) ctx.skip();
264
+ const [a, sidA] = await guest();
265
+ const [b, sidB] = await guest();
266
+ const qa = viewId();
267
+ await register(qa, sidA);
268
+ const before = await row(qa);
269
+
270
+ expect(await b.query('SELECT id, surql FROM _00_query')).toEqual([[]]);
271
+ expect(await readBack(b, qa)).toBeNull();
272
+ // The membership batch read. 3.0.x answers a hidden row with an empty
273
+ // object, which the client skips for want of an `id`.
274
+ const [batch] = (await b.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
275
+ Array<{ id?: unknown }>,
276
+ ];
277
+ expect(batch.filter((r) => r?.id)).toEqual([]);
278
+
279
+ expect(
280
+ await b.query(`UPDATE $id SET surql = 'SELECT * FROM secret', ttl = 1000w`, { id: qa })
281
+ ).toEqual([[]]);
282
+ expect(await heartbeatFound(b, qa)).toBe(false);
283
+ expect(await b.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
284
+ { released: false, remaining: 0 },
285
+ ]);
286
+ // Session ids are not secret (they ride on `_00_list_ref_anon`), so naming
287
+ // the owner's must not help, over the socket or over the beacon's HTTP path.
288
+ await b.query('RETURN fn::query::leave($id, [$sid])', { id: qa, sid: sidA });
289
+ await beacon(leaveViewsBeacon([qa], [sidA])!);
290
+ await b.query('DELETE $id', { id: qa });
291
+
292
+ expect(await row(qa)).toEqual(before);
293
+ expect(await count()).toBe(1);
294
+ // ...and the owner never noticed.
295
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
296
+ expect(sidB).not.toBe(sidA);
297
+ });
298
+
299
+ it('no caller can plant a row, so nothing the SSP re-registers at boot comes from a client', async (ctx) => {
300
+ if (!reachable) ctx.skip();
301
+ const [g, sidG] = await guest();
302
+ const [u, , authU] = await member('alice');
303
+ const planted = (sid: string, owner: string) =>
304
+ `SET auth_id = '${owner}', surql = 'SELECT * FROM item', params = {}, ttl = 1h,
305
+ subscribers = [{ id: '${sid}', seenAt: time::now() }]`;
306
+
307
+ await g.query(`CREATE _00_query:g1 ${planted(sidG, 'anon')}`);
308
+ await g.query(`UPSERT _00_query:g2 ${planted(sidG, 'anon')}`);
309
+ await g.query(
310
+ `INSERT INTO _00_query { id: 'g3', auth_id: 'anon', ttl: 1h, subscribers: [{ id: '${sidG}' }] }`
311
+ );
312
+ await u.query(`CREATE _00_query:u1 ${planted('x', authU)}`);
313
+ await u.query(`UPSERT _00_query:u2 ${planted('x', authU)}`);
314
+ expect(await count()).toBe(0);
315
+ });
316
+
317
+ it('the owner cannot rewrite what the SSP trusts on its own row either', async (ctx) => {
318
+ if (!reachable) ctx.skip();
319
+ const [g, sidG] = await guest();
320
+ const [u, sidU, authU] = await member('alice');
321
+ const qg = viewId();
322
+ const qu = viewId();
323
+ await register(qg, sidG);
324
+ await register(qu, sidU, authU);
325
+ // One field per statement: a write the table rule rejects as a whole would
326
+ // hide whether the field itself is locked. `params.auth.id` is the identity
327
+ // the SSP injects into permission predicates when it rebuilds the view at
328
+ // boot, `surql` skips the allowlist there, and turning a user's row into an
329
+ // anonymous one would publish its edges to the world-readable anon table.
330
+ const tampers = (owner: string): Record<string, string> => ({
331
+ surql: `surql = 'SELECT * FROM secret'`,
332
+ params: `params = { auth: { id: 'user:victim' }, access: 'account' }`,
333
+ ttl: 'ttl = 1000w',
334
+ auth_id: `auth_id = '${owner === 'anon' ? 'user:victim' : 'anon'}'`,
335
+ clientId: `clientId = 'forged'`,
336
+ rowCount: 'rowCount = 0',
337
+ state: `state = 'materializing'`,
338
+ lastActiveAt: 'lastActiveAt = time::now() + 1000w',
339
+ });
340
+
341
+ for (const [db, id, owner] of [
342
+ [g, qg, 'anon'],
343
+ [u, qu, authU],
344
+ ] as const) {
345
+ for (const [field, set] of Object.entries(tampers(owner))) {
346
+ const before = await row(id);
347
+ await db.query(`UPDATE $id SET ${set}`, { id });
348
+ expect((await row(id))?.[field], `${owner} ${field}`).toEqual(before?.[field]);
349
+ }
350
+ }
351
+ });
352
+
353
+ it('the owning guest keeps its heartbeat and read-back, and after a reconnect gets them back by registering again', async (ctx) => {
354
+ if (!reachable) ctx.skip();
355
+ const [a, sidA] = await guest();
356
+ const [b] = await guest();
357
+ const qa = viewId();
358
+ await register(qa, sidA);
359
+
360
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
361
+ const [batch] = (await a.query(queryRowCountBatchSelect(), { ins: [qa] })) as [
362
+ Array<{ id?: unknown; rowCount?: number }>,
363
+ ];
364
+ expect(batch.map((r) => r.rowCount)).toEqual([3]);
365
+ const stamped = (await row(qa))?.lastActiveAt;
366
+ expect(await heartbeatFound(a, qa)).toBe(true);
367
+ expect((await row(qa))?.lastActiveAt).not.toEqual(stamped);
368
+
369
+ // A reconnect is a new session. Until it registers again its heartbeat
370
+ // finds nothing, which the client reads as a reclaimed view and
371
+ // re-registers; the SSP's registration then adds the new session.
372
+ await a.close();
373
+ const [a2, sidA2] = await guest();
374
+ expect(await heartbeatFound(a2, qa)).toBe(false);
375
+ expect(await readBack(a2, qa)).toBeNull();
376
+ await register(qa, sidA2);
377
+ expect(await heartbeatFound(a2, qa)).toBe(true);
378
+ expect(await readBack(a2, qa)).toEqual({ rowCount: 3, state: 'ready' });
379
+ expect((await row(qa))?.subscribers.map((s: { id: string }) => s.id)).toEqual([sidA, sidA2]);
380
+
381
+ // The bystander is still out.
382
+ expect(await heartbeatFound(b, qa)).toBe(false);
383
+ expect(await readBack(b, qa)).toBeNull();
384
+ });
385
+
386
+ it("a guest who read another guest's view id cannot join it through fn::query::register; the owner's reconnect can", async (ctx) => {
387
+ if (!reachable) ctx.skip();
388
+ if (!(await registerFunctions())) ctx.skip();
389
+ const [a, sidA] = await guest();
390
+ const [b] = await guest();
391
+ const qa = viewId();
392
+ const register = (db: Surreal, owner?: string) =>
393
+ db.query('fn::query::register($config)', {
394
+ config: {
395
+ id: qa,
396
+ surql: 'SELECT * FROM item',
397
+ params: {},
398
+ ttl: '1h',
399
+ ...(owner ? { owner } : {}),
400
+ },
401
+ });
402
+
403
+ await register(a, 'secret-a');
404
+ const sent = posted.at(-1)!;
405
+ expect(sent).toMatchObject({ clientId: sidA, ownerHash: sha256('secret-a') });
406
+ // Only the hash leaves the database, and never inside `params` (the SSP's
407
+ // merge key is built from them).
408
+ expect(JSON.stringify(sent)).not.toContain('secret-a');
409
+ expect(String(sent.params)).not.toContain(sha256('secret-a'));
410
+ const before = await row(qa);
411
+ expect(before?.ownerHash).toBe(sha256('secret-a'));
412
+
413
+ // Anonymous view ids are world-readable on `_00_list_ref_anon`. With its
414
+ // own secret or with none, the stranger's registration is refused, so it
415
+ // never lands in `subscribers` and its DELETE still matches nothing.
416
+ await expect(register(b, 'secret-b')).rejects.toThrow();
417
+ await expect(register(b)).rejects.toThrow();
418
+ await b.query('DELETE $id', { id: qa });
419
+ expect(await row(qa)).toEqual(before);
420
+ expect(await readBack(b, qa)).toBeNull();
421
+
422
+ // The owner's reconnect: a new socket, the same page secret.
423
+ await a.close();
424
+ const [a2, sidA2] = await guest();
425
+ expect(await heartbeatFound(a2, qa)).toBe(false);
426
+ await register(a2, 'secret-a');
427
+ expect((await row(qa))?.subscribers.map((s: { id: string }) => s.id)).toEqual([sidA, sidA2]);
428
+ expect(await heartbeatFound(a2, qa)).toBe(true);
429
+ expect(await readBack(a2, qa)).toEqual({ rowCount: 3, state: 'ready' });
430
+ });
431
+
432
+ it('no caller can read or rewrite the owner hash, not even the owner', async (ctx) => {
433
+ if (!reachable) ctx.skip();
434
+ const [g, sidG] = await guest();
435
+ const qg = viewId();
436
+ await register(qg, sidG);
437
+ await root.query(`UPDATE $id SET ownerHash = 'stored'`, { id: qg });
438
+
439
+ const [own] = (await g.query('SELECT * FROM ONLY $id', { id: qg })) as [
440
+ Record<string, unknown>,
441
+ ];
442
+ expect(own.subscribers).toBeDefined();
443
+ expect(own).not.toHaveProperty('ownerHash');
444
+ for (const set of [`ownerHash = 'mine'`, 'ownerHash = NONE']) {
445
+ await g.query(`UPDATE $id SET ${set}`, { id: qg });
446
+ expect((await row(qg))?.ownerHash).toBe('stored');
447
+ }
448
+ // The heartbeat still writes what it needs to around the frozen field.
449
+ expect(await heartbeatFound(g, qg)).toBe(true);
450
+ });
451
+
452
+ // Without `anonymousLiveQueries` the SSP stores a signed-out registration
453
+ // with no identity at all (`auth_id = ''`). The rule admitted only `'anon'`
454
+ // rows to their subscribers, so such a page could not read its own row back
455
+ // and its preload never settled.
456
+ it("a signed-out view stored with no identity (anonymous live queries off) answers to its own session, and to nobody else", async (ctx) => {
457
+ if (!reachable) ctx.skip();
458
+ const [a, sidA] = await guest();
459
+ const [b] = await guest();
460
+ const [u] = await member('alice');
461
+ const qa = viewId();
462
+ await register(qa, sidA, '');
463
+ expect((await row(qa))?.auth_id).toBe('');
464
+
465
+ expect(await readBack(a, qa)).toEqual({ rowCount: 3, state: 'ready' });
466
+ expect(await heartbeatFound(a, qa)).toBe(true);
467
+
468
+ const before = await row(qa);
469
+ for (const other of [b, u]) {
470
+ expect(await readBack(other, qa)).toBeNull();
471
+ expect(await heartbeatFound(other, qa)).toBe(false);
472
+ expect(await other.query('SELECT id FROM _00_query')).toEqual([[]]);
473
+ await other.query('DELETE $id', { id: qa });
474
+ }
475
+ expect(await row(qa)).toEqual(before);
476
+
477
+ expect(await a.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
478
+ { released: true, remaining: 0 },
479
+ ]);
480
+ expect(await row(qa)).toBeUndefined();
481
+ });
482
+
483
+ it('the owning guest can still release its view', async (ctx) => {
484
+ if (!reachable) ctx.skip();
485
+ const [a, sidA] = await guest();
486
+ const qa = viewId();
487
+ await register(qa, sidA);
488
+ expect(await a.query('RETURN fn::query::unsubscribe($id)', { id: qa })).toEqual([
489
+ { released: true, remaining: 0 },
490
+ ]);
491
+ expect(await row(qa)).toBeUndefined();
492
+ });
493
+
494
+ it("a signed-in user's views are untouched: other tabs share them, the pagehide leave still pulls the expiry in", async (ctx) => {
495
+ if (!reachable) ctx.skip();
496
+ const [u, sidU, authU, token] = await member('alice');
497
+ const [bob] = await member('bob');
498
+ const [g] = await guest();
499
+ const qu = viewId();
500
+ await register(qu, sidU, authU);
501
+
502
+ // A second tab of the same user is not a subscriber yet and still gets in.
503
+ const tab2 = new Surreal();
504
+ open.push(tab2);
505
+ await tab2.connect(endpoint, { reconnect: false });
506
+ await tab2.use({ namespace: NS, database });
507
+ await tab2.authenticate(token);
508
+ expect(await heartbeatFound(tab2, qu)).toBe(true);
509
+ expect(await readBack(tab2, qu)).toEqual({ rowCount: 3, state: 'ready' });
510
+
511
+ expect(await heartbeatFound(bob, qu)).toBe(false);
512
+ expect(await readBack(g, qu)).toBeNull();
513
+
514
+ // The beacon names both tabs' sessions over a fresh HTTP session.
515
+ const [sid2] = (await tab2.query('RETURN <string>session::id()')) as [string];
516
+ await beacon(leaveViewsBeacon([qu], [sidU, sid2])!, token);
517
+ const [left] = (await root.query(
518
+ 'SELECT VALUE duration::secs((lastActiveAt + ttl) - time::now()) FROM ONLY $id',
519
+ {
520
+ id: qu,
521
+ }
522
+ )) as [number];
523
+ expect(left).toBeGreaterThan(0);
524
+ expect(left).toBeLessThanOrEqual(60);
525
+ expect((await row(qu))?.subscribers).toEqual([]);
526
+ });
527
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { columnsFor, defaultEnv, listRefTable } from './env';
3
+ import { emptyState } from '../state/client-state';
4
+
5
+ const schema = { tables: [{ name: 'thing', columns: { a: { type: 'string' } } }] } as any;
6
+
7
+ describe('SagaEnv', () => {
8
+ it('defaultEnv applies overrides', () => {
9
+ expect(defaultEnv(schema).outboxBatchSize).toBe(50);
10
+ expect(defaultEnv(schema, { outboxBatchSize: 5 }).outboxBatchSize).toBe(5);
11
+ });
12
+ it('listRefTable: per-user, anonymous when enabled, global otherwise', () => {
13
+ const s = emptyState({ tabId: 't' });
14
+ expect(listRefTable(defaultEnv(schema), { ...s, userId: 'user:abc' })).toBe('_00_list_ref_user_abc');
15
+ expect(listRefTable(defaultEnv(schema, { anonLive: true }), s)).toBe('_00_list_ref_anon');
16
+ expect(listRefTable(defaultEnv(schema), s)).toBe('_00_list_ref');
17
+ });
18
+ it('columnsFor: schema table, framework table, unknown', () => {
19
+ expect(columnsFor(defaultEnv(schema), 'thing')).toEqual({ a: { type: 'string' } });
20
+ expect(columnsFor(defaultEnv(schema), '_00_feature_flag')).toEqual({});
21
+ expect(columnsFor(defaultEnv(schema), 'nope')).toBeNull();
22
+ });
23
+ });
@@ -0,0 +1,65 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { RefMode } from '../modules/ref-tables';
3
+ import { ANON_USER_ID, DEFAULT_REF_MODE, listRefTableFor } from '../modules/ref-tables';
4
+ import type { ClientState } from '../state/client-state';
5
+ import {
6
+ DEGRADE_AFTER_FAILURES,
7
+ LIST_REF_POLL_BASE_MS,
8
+ MATERIALIZE_DEBOUNCE_MS,
9
+ OUTBOX_BATCH_SIZE,
10
+ PUSH_TIMEOUT_MS,
11
+ } from '../kernel/constants';
12
+
13
+ /**
14
+ * Static configuration the sagas read. Plain data, built once by the runtime
15
+ * from `Sp00kyConfig`; sagas never see the config object itself.
16
+ */
17
+ export interface SagaEnv {
18
+ readonly schema: SchemaStructure;
19
+ readonly refMode: RefMode;
20
+ readonly anonLive: boolean;
21
+ readonly remoteTimeoutMs: number;
22
+ readonly pushTimeoutMs: number;
23
+ readonly outboxBatchSize: number;
24
+ readonly degradeAfter: number;
25
+ readonly materializeDebounceMs: number;
26
+ readonly pollBaseMs: number;
27
+ readonly defaultTtlMs: number;
28
+ }
29
+
30
+ export function defaultEnv(schema: SchemaStructure, over: Partial<SagaEnv> = {}): SagaEnv {
31
+ return {
32
+ schema,
33
+ refMode: DEFAULT_REF_MODE,
34
+ anonLive: false,
35
+ remoteTimeoutMs: 60_000,
36
+ pushTimeoutMs: PUSH_TIMEOUT_MS,
37
+ outboxBatchSize: OUTBOX_BATCH_SIZE,
38
+ degradeAfter: DEGRADE_AFTER_FAILURES,
39
+ materializeDebounceMs: MATERIALIZE_DEBOUNCE_MS,
40
+ pollBaseMs: LIST_REF_POLL_BASE_MS,
41
+ defaultTtlMs: 600_000,
42
+ ...over,
43
+ };
44
+ }
45
+
46
+ /** The `_00_list_ref` table this session reads: per-user, anonymous, or global. */
47
+ export function listRefTable(env: SagaEnv, s: ClientState): string {
48
+ const userId = s.userId ?? (env.anonLive ? ANON_USER_ID : null);
49
+ return listRefTableFor(env.refMode, userId);
50
+ }
51
+
52
+ /**
53
+ * Whether this session can be sent a view's membership at all. A signed-out
54
+ * session reads edges only from `_00_list_ref_anon`, and only with
55
+ * `enableAnonymousLiveQueries`: without it the SSP files its views under no
56
+ * identity in `_00_list_ref`, whose rule shows those edges to no caller. Such
57
+ * a view can register and report its row count, but never its rows.
58
+ */
59
+ export const receivesMembership = (env: SagaEnv, s: ClientState): boolean => s.userId !== null || env.anonLive;
60
+
61
+ export function columnsFor(env: SagaEnv, table: string): Record<string, unknown> | null {
62
+ const found = env.schema.tables.find((t) => t.name === table);
63
+ if (found) return found.columns as Record<string, unknown>;
64
+ return table.startsWith('_00_') ? {} : null;
65
+ }