@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381

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 +91 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2238 -144
  4. package/dist/index.js +18149 -2475
  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/pure.d.ts +401 -0
  9. package/dist/pure.js +24 -0
  10. package/dist/sqlite-open.js +392 -0
  11. package/dist/sqlite-worker.d.ts +1 -0
  12. package/dist/sqlite-worker.js +439 -0
  13. package/dist/sw.d.ts +207 -0
  14. package/dist/sw.js +393 -0
  15. package/dist/tabs-broker-worker.d.ts +8 -0
  16. package/dist/tabs-broker-worker.js +477 -0
  17. package/dist/types.d.ts +2082 -39
  18. package/dist/worker-edges.js +498 -0
  19. package/dist/worker-impersonation.js +60 -0
  20. package/dist/worker-ref-tables.d.ts +48 -0
  21. package/dist/worker-types.d.ts +112 -0
  22. package/dist/worker-types.js +73 -0
  23. package/package.json +28 -7
  24. package/scripts/check-broker-bundle.mjs +33 -0
  25. package/scripts/check-worker-entries.mjs +173 -0
  26. package/skills/sp00ky-core/SKILL.md +42 -8
  27. package/skills/sp00ky-core/references/config.md +8 -2
  28. package/src/boot/auth-flip.saga.test.ts +174 -0
  29. package/src/boot/auth-flip.saga.ts +77 -0
  30. package/src/boot/boot.saga.test.ts +203 -0
  31. package/src/boot/boot.saga.ts +146 -0
  32. package/src/boot/bucket-switch.saga.test.ts +191 -0
  33. package/src/boot/bucket-switch.saga.ts +151 -0
  34. package/src/boot/preload.saga.test.ts +96 -0
  35. package/src/boot/preload.saga.ts +45 -0
  36. package/src/bucket-blurhash.test.ts +148 -0
  37. package/src/build-globals.d.ts +12 -0
  38. package/src/client/bucket-handle.test.ts +195 -0
  39. package/src/client/bucket-handle.ts +352 -0
  40. package/src/client/router.test.ts +59 -0
  41. package/src/client/router.ts +104 -0
  42. package/src/client/runtime.test.ts +319 -0
  43. package/src/client/runtime.ts +307 -0
  44. package/src/client/services.test.ts +74 -0
  45. package/src/client/services.ts +437 -0
  46. package/src/client/sp00ky-client.test.ts +428 -0
  47. package/src/client/sp00ky-client.ts +737 -0
  48. package/src/events/index.ts +3 -0
  49. package/src/index.ts +86 -1
  50. package/src/kernel/constants.test.ts +19 -0
  51. package/src/kernel/constants.ts +94 -0
  52. package/src/kernel/effects.test.ts +40 -0
  53. package/src/kernel/effects.ts +171 -0
  54. package/src/kernel/events.ts +60 -0
  55. package/src/kernel/interpreter.test.ts +116 -0
  56. package/src/kernel/interpreter.ts +148 -0
  57. package/src/kernel/saga.test.ts +89 -0
  58. package/src/kernel/saga.ts +73 -0
  59. package/src/live/index.test.ts +512 -0
  60. package/src/live/index.ts +1059 -0
  61. package/src/modules/app-release/index.test.ts +125 -0
  62. package/src/modules/app-release/index.ts +209 -0
  63. package/src/modules/auth/auth.local-first.test.ts +228 -0
  64. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  65. package/src/modules/auth/events/index.ts +7 -1
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.integration.test.ts +209 -0
  68. package/src/modules/auth/impersonation.test.ts +377 -0
  69. package/src/modules/auth/impersonation.ts +179 -0
  70. package/src/modules/auth/index.ts +538 -14
  71. package/src/modules/channel/channel.test.ts +752 -0
  72. package/src/modules/channel/index.ts +1153 -0
  73. package/src/modules/channel/webrtc.ts +472 -0
  74. package/src/modules/crdt/acceptance.ts +75 -0
  75. package/src/modules/crdt/bytes.ts +50 -0
  76. package/src/modules/crdt/crdt-field.ts +125 -0
  77. package/src/modules/crdt/crdt.test.ts +546 -0
  78. package/src/modules/crdt/hash.ts +103 -0
  79. package/src/modules/crdt/index.ts +821 -0
  80. package/src/modules/crdt/loro-loader.ts +25 -0
  81. package/src/modules/crdt/mirror.ts +37 -0
  82. package/src/modules/devtools/flags.ts +349 -0
  83. package/src/modules/devtools/index.ts +746 -93
  84. package/src/modules/devtools/mutations.test.ts +92 -0
  85. package/src/modules/devtools/mutations.ts +160 -0
  86. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  87. package/src/modules/devtools/state-shape.test.ts +263 -0
  88. package/src/modules/devtools/storage-info.test.ts +79 -0
  89. package/src/modules/devtools/storage-info.ts +168 -0
  90. package/src/modules/devtools/versions.test.ts +74 -0
  91. package/src/modules/devtools/versions.ts +110 -0
  92. package/src/modules/feature-flag/index.test.ts +298 -0
  93. package/src/modules/feature-flag/index.ts +316 -0
  94. package/src/modules/ref-tables.test.ts +93 -0
  95. package/src/modules/ref-tables.ts +91 -0
  96. package/src/modules/web-push/index.test.ts +824 -0
  97. package/src/modules/web-push/index.ts +1027 -0
  98. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  99. package/src/mutation/jobs.test.ts +25 -0
  100. package/src/mutation/jobs.ts +41 -0
  101. package/src/mutation/mutation-id.test.ts +37 -0
  102. package/src/mutation/mutation-id.ts +35 -0
  103. package/src/mutation/pending-writes.integration.test.ts +175 -0
  104. package/src/mutation/push.saga.test.ts +361 -0
  105. package/src/mutation/push.saga.ts +245 -0
  106. package/src/mutation/rows.test.ts +278 -0
  107. package/src/mutation/rows.ts +515 -0
  108. package/src/mutation/tray.saga.test.ts +63 -0
  109. package/src/mutation/tray.saga.ts +48 -0
  110. package/src/mutation/write.saga.test.ts +436 -0
  111. package/src/mutation/write.saga.ts +384 -0
  112. package/src/pure.ts +66 -0
  113. package/src/push/types.ts +174 -0
  114. package/src/query/anon-query-isolation.integration.test.ts +531 -0
  115. package/src/query/env.test.ts +23 -0
  116. package/src/query/env.ts +65 -0
  117. package/src/query/fetch.saga.test.ts +199 -0
  118. package/src/query/fetch.saga.ts +120 -0
  119. package/src/query/hash.test.ts +14 -0
  120. package/src/query/hash.ts +18 -0
  121. package/src/query/id-set-plan.test.ts +132 -0
  122. package/src/query/lifecycle.saga.test.ts +274 -0
  123. package/src/query/lifecycle.saga.ts +186 -0
  124. package/src/query/list-ref-live.integration.test.ts +203 -0
  125. package/src/query/materialize.saga.test.ts +200 -0
  126. package/src/query/materialize.saga.ts +102 -0
  127. package/src/query/materialize.test.ts +58 -0
  128. package/src/query/materialize.ts +57 -0
  129. package/src/query/membership.saga.test.ts +514 -0
  130. package/src/query/membership.saga.ts +339 -0
  131. package/src/query/membership.test.ts +151 -0
  132. package/src/query/membership.ts +234 -0
  133. package/src/query/register.saga.test.ts +369 -0
  134. package/src/query/register.saga.ts +293 -0
  135. package/src/query/render-set.test.ts +47 -0
  136. package/src/query/render-set.ts +71 -0
  137. package/src/query/session-guard.integration.test.ts +304 -0
  138. package/src/query/sql.test.ts +118 -0
  139. package/src/query/sql.ts +174 -0
  140. package/src/query/window-query.test.ts +70 -0
  141. package/src/query/window-query.ts +208 -0
  142. package/src/services/blobs/blob-cache.test.ts +359 -0
  143. package/src/services/blobs/blob-cache.ts +603 -0
  144. package/src/services/blobs/blob-manifest.ts +227 -0
  145. package/src/services/blobs/blob-store.test.ts +77 -0
  146. package/src/services/blobs/blob-store.ts +359 -0
  147. package/src/services/blobs/blob.fixture.ts +90 -0
  148. package/src/services/blobs/index.ts +70 -0
  149. package/src/services/database/cache-engine.ts +209 -0
  150. package/src/services/database/connection-supervisor.test.ts +318 -0
  151. package/src/services/database/connection-supervisor.ts +422 -0
  152. package/src/services/database/database.query-timeout.test.ts +140 -0
  153. package/src/services/database/database.responses.test.ts +43 -0
  154. package/src/services/database/database.ts +151 -56
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/index.ts +7 -0
  158. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  159. package/src/services/database/local-migrator.integration.test.ts +110 -0
  160. package/src/services/database/local-migrator.ts +54 -9
  161. package/src/services/database/local.test.ts +150 -0
  162. package/src/services/database/local.ts +503 -59
  163. package/src/services/database/plan-render.test.ts +159 -0
  164. package/src/services/database/plan-render.ts +108 -0
  165. package/src/services/database/relation-resolver.test.ts +484 -0
  166. package/src/services/database/relation-resolver.ts +0 -0
  167. package/src/services/database/remote.ts +283 -4
  168. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  169. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  170. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  171. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  172. package/src/services/database/sqlite-column-reset.ts +91 -0
  173. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  174. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  175. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  176. package/src/services/database/sqlite-lock-verify.ts +45 -0
  177. package/src/services/database/sqlite-open.test.ts +150 -0
  178. package/src/services/database/sqlite-open.ts +164 -0
  179. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.ts +196 -0
  181. package/src/services/database/sqlite-projection.test.ts +121 -0
  182. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  183. package/src/services/database/sqlite-select.test.ts +261 -0
  184. package/src/services/database/sqlite-select.ts +131 -0
  185. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  186. package/src/services/database/sqlite-transport.ts +224 -0
  187. package/src/services/database/sqlite-worker.ts +437 -0
  188. package/src/services/database/surql-translate.ts +417 -0
  189. package/src/services/database/surreal-cache-engine.ts +163 -0
  190. package/src/services/logger/index.test.ts +132 -0
  191. package/src/services/logger/index.ts +187 -6
  192. package/src/services/persistence/resilient.ts +8 -1
  193. package/src/services/persistence/surrealdb.ts +2 -2
  194. package/src/services/stream-processor/index.ts +801 -63
  195. package/src/services/stream-processor/permissions.test.ts +47 -0
  196. package/src/services/stream-processor/permissions.ts +53 -0
  197. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  198. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  199. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  200. package/src/services/stream-processor/wasm-types.ts +57 -1
  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 -394
  211. package/src/state/client-state.ts +231 -0
  212. package/src/state/lifecycle.test.ts +184 -0
  213. package/src/state/lifecycle.ts +127 -0
  214. package/src/state/reducers.test.ts +337 -0
  215. package/src/state/reducers.ts +571 -0
  216. package/src/state/selectors.test.ts +203 -0
  217. package/src/state/selectors.ts +264 -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 +229 -0
  224. package/src/sync/live.saga.ts +116 -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 +694 -7
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +57 -0
  241. package/src/utils/error-classification.test.ts +116 -0
  242. package/src/utils/error-classification.ts +74 -0
  243. package/src/utils/index.ts +83 -6
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +63 -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 +22 -11
  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 -882
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -78
  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 -90
  262. package/src/modules/sync/queue/queue-up.ts +0 -224
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -422
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -169
@@ -0,0 +1,304 @@
1
+ import { Socket } from 'node:net';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
6
+ import { RecordId, Surreal } from 'surrealdb';
7
+ import { liveEndpoint } from '../live/index';
8
+ import { isRefusedSessionError } from '../utils/error-classification';
9
+ import { heartbeatBatch, heartbeatRowGone, queryRowCountSelect } from './sql';
10
+
11
+ /**
12
+ * The app's session guard (`sessionGuard` in sp00ky.yml, the SESSION GUARD
13
+ * block of `meta_tables_remote.surql`) ends sync for a WebSocket that is still
14
+ * open. SurrealDB checks a token only when a socket authenticates, so before
15
+ * the guard a socket that authenticated before a password reset kept reading
16
+ * its user's edges, heartbeating its views and registering new ones until it
17
+ * happened to reconnect.
18
+ *
19
+ * Runs the shipped guard, `_00_query` and `_00_list_ref` blocks, the
20
+ * `fn::query::*` functions and the per-user edge-table rule ssp-node defines,
21
+ * against a real SurrealDB, because the guarantee is the engine's evaluation
22
+ * of those rules, LIVE notifications included. Boot one first, e.g.:
23
+ * docker run -d --name spooky-surreal-it -p 18011:8000 \
24
+ * surrealdb/surrealdb:v3.1.5 start --user root --pass root --allow-all memory
25
+ *
26
+ * Override the endpoint with SURREAL_IT_ENDPOINT. Skips if unreachable.
27
+ */
28
+ const endpoint = liveEndpoint(process.env.SURREAL_IT_ENDPOINT ?? 'ws://127.0.0.1:18011');
29
+ const NS = 'it';
30
+
31
+ /** The guard WhitePawn ships: a password reset stamps `session_epoch`. */
32
+ const GUARD = '$auth.session_epoch = NONE OR ($token.iat ?? 0) >= $auth.session_epoch';
33
+
34
+ const repoFile = (path: string) =>
35
+ readFileSync(fileURLToPath(new URL(`../../../../${path}`, import.meta.url)), 'utf8');
36
+ const cliSource = (file: string) => repoFile(`apps/cli/src/${file}`);
37
+
38
+ function slice(src: string, from: string, to: string): string {
39
+ const start = src.indexOf(from);
40
+ const end = src.indexOf(to, start);
41
+ if (start < 0 || end < start) throw new Error(`block ${from} .. ${to} not found`);
42
+ return src.slice(start, end);
43
+ }
44
+
45
+ /**
46
+ * The SESSION GUARD block, with `expr` put in the way `spky migrate` does
47
+ * (`schema_builder::apply_session_guard`, whose marker is read from the Rust
48
+ * source so the two cannot drift).
49
+ */
50
+ function guardSchema(expr?: string): string {
51
+ const block = slice(
52
+ cliSource('meta_tables_remote.surql'),
53
+ '-- SPOOKY SESSION GUARD',
54
+ '-- SPOOKY INCANTATION'
55
+ );
56
+ const m = /pub const SESSION_GUARD_DEFAULT: &str = "([^"]+)";/.exec(
57
+ repoFile('apps/cli/src/schema_builder.rs')
58
+ );
59
+ if (!m || !block.includes(m[1])) throw new Error('session guard marker not found');
60
+ return expr ? block.replace(m[1], `RETURN (${expr}) == true;`) : block;
61
+ }
62
+
63
+ /** Just the tables this test needs: the rest of the file needs modules. */
64
+ const queryTableSchema = () =>
65
+ slice(
66
+ cliSource('meta_tables_remote.surql'),
67
+ 'DEFINE TABLE OVERWRITE _00_query SCHEMALESS',
68
+ '-- SPOOKY LIST REF'
69
+ );
70
+ const listRefSchema = () =>
71
+ slice(
72
+ cliSource('meta_tables_remote.surql'),
73
+ 'DEFINE TABLE OVERWRITE _00_list_ref SCHEMALESS',
74
+ '-- SPOOKY STREAM PROCESSOR STATE'
75
+ );
76
+
77
+ /** The rule ssp-node puts on `_00_list_ref_user_<uid>` (`user_list_ref_ddl` in tables.rs). */
78
+ function userListRefTable(uid: string): string {
79
+ const m = /PERMISSIONS FOR select WHERE (\$auth\.id = user:\{uid\}[^,]*),/.exec(
80
+ repoFile('packages/ssp-node/src/tables.rs')
81
+ );
82
+ if (!m) throw new Error('per-user list_ref rule not found in ssp-node/src/tables.rs');
83
+ return `DEFINE TABLE OVERWRITE _00_list_ref_user_${uid} SCHEMALESS
84
+ PERMISSIONS FOR select WHERE ${m[1].replace('{uid}', uid)}, FOR create, update, delete WHERE false;`;
85
+ }
86
+
87
+ /** An app schema: a hidden `session_epoch` and an app table that asks the guard too. */
88
+ const APP = `
89
+ DEFINE TABLE OVERWRITE user SCHEMAFULL PERMISSIONS FOR select WHERE id = $auth.id;
90
+ DEFINE FIELD OVERWRITE name ON TABLE user TYPE string;
91
+ DEFINE FIELD OVERWRITE session_epoch ON TABLE user TYPE option<int>
92
+ PERMISSIONS FOR select WHERE false, FOR create, update WHERE false;
93
+ DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
94
+ SIGNUP (CREATE ONLY type::record('user', $name) SET name = $name)
95
+ SIGNIN (SELECT * FROM user WHERE name = $name)
96
+ DURATION FOR TOKEN 1h, FOR SESSION 1h;
97
+ DEFINE TABLE OVERWRITE game SCHEMALESS
98
+ PERMISSIONS FOR select WHERE owner = $auth.id AND fn::sp00ky::session_live();
99
+ CREATE game:1 SET owner = user:alice;
100
+ `;
101
+
102
+ /** What the SSP writes, as root, when a session registers a view (`node.rs`, cold path). */
103
+ const SSP_REGISTER = `
104
+ UPSERT $id SET clientId = $sid, auth_id = 'user:alice', surql = 'SELECT * FROM game',
105
+ params = { auth: { id: 'user:alice' }, access: 'account' }, ttl = 1h, lastActiveAt = time::now(),
106
+ rowCount = 1, state = 'ready',
107
+ subscribers = array::append(
108
+ array::filter(subscribers ?? [], |$s| <string>$s.id != $sid AND <datetime>$s.seenAt + 1h > time::now()),
109
+ { id: $sid, seenAt: time::now() })`;
110
+
111
+ const viewId = () =>
112
+ new RecordId('_00_query', createHash('sha256').update(randomUUID()).digest('hex'));
113
+
114
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
115
+
116
+ describe('session guard ends sync for an open socket (real SurrealDB)', () => {
117
+ let root: Surreal;
118
+ let database = '';
119
+ let reachable = false;
120
+ const open: Surreal[] = [];
121
+
122
+ beforeAll(async () => {
123
+ const url = new URL(endpoint);
124
+ reachable = await new Promise<boolean>((resolve) => {
125
+ const socket = new Socket();
126
+ const done = (ok: boolean) => {
127
+ socket.destroy();
128
+ resolve(ok);
129
+ };
130
+ socket.setTimeout(1_000, () => done(false));
131
+ socket.once('error', () => done(false));
132
+ socket.connect(Number(url.port || 80), url.hostname, () => done(true));
133
+ });
134
+ });
135
+
136
+ /** Root, a fresh database, the app and sp00ky's rules with `guard` in place. */
137
+ async function boot(guard?: string): Promise<void> {
138
+ database = `session_guard_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
139
+ root = new Surreal();
140
+ await root.connect(endpoint, { reconnect: false });
141
+ await root.signin({ username: 'root', password: 'root' });
142
+ await root.use({ namespace: NS, database });
143
+ await root.query(guardSchema(guard));
144
+ await root.query(APP);
145
+ await root.query(queryTableSchema());
146
+ await root.query(listRefSchema());
147
+ await root.query(userListRefTable('alice'));
148
+ await root.query(cliSource('functions_remote.surql'));
149
+ // The guard throws before the post; a live session's post fails on this
150
+ // closed port, which is not a refused session.
151
+ await root.query(
152
+ cliSource('functions_remote_singlenode.surql')
153
+ .replaceAll('{{ENDPOINT}}', 'http://127.0.0.1:9')
154
+ .replaceAll('{{SECRET}}', 'it-secret')
155
+ );
156
+ }
157
+
158
+ afterEach(async () => {
159
+ if (!reachable || !root) return;
160
+ for (const db of open.splice(0)) await db.close().catch(() => undefined);
161
+ await root.query(`REMOVE DATABASE ${database}`).catch(() => undefined);
162
+ await root.close();
163
+ });
164
+
165
+ /** Alice on a socket of her own, and that socket's `session::id()`. */
166
+ async function alice(how: 'signup' | 'signin'): Promise<[Surreal, string]> {
167
+ const db = new Surreal();
168
+ open.push(db);
169
+ await db.connect(endpoint, { reconnect: false });
170
+ await db.use({ namespace: NS, database });
171
+ await db[how]({ namespace: NS, database, access: 'account', variables: { name: 'alice' } });
172
+ const [sid] = (await db.query('RETURN <string>session::id()')) as [string];
173
+ return [db, sid];
174
+ }
175
+
176
+ /** What a password reset does: every token issued before now stops counting. */
177
+ async function resetPassword(stale: Surreal): Promise<void> {
178
+ const [iat] = (await stale.query('RETURN $token.iat')) as [number];
179
+ const epoch = iat + 1;
180
+ await root.query('UPDATE user:alice SET session_epoch = $epoch', { epoch });
181
+ // `iat` has second precision: the next sign-in must land at or past the epoch.
182
+ while (Date.now() / 1000 < epoch) await sleep(50);
183
+ }
184
+
185
+ /** LIVE on the per-user edge table; returns the notifications seen so far. */
186
+ async function watchEdges(db: Surreal): Promise<unknown[]> {
187
+ const seen: unknown[] = [];
188
+ const [id] = (await db.query('LIVE SELECT * FROM _00_list_ref_user_alice')) as [unknown];
189
+ const stream = await db.liveOf(id as never);
190
+ // Typed by the SDK (`LiveMessage`), not a narrower hand-written shape:
191
+ // the hand-written one failed `tsc` and so the core Test job.
192
+ stream.subscribe((message) => {
193
+ if (message.action !== 'KILLED') seen.push(message.value);
194
+ });
195
+ return seen;
196
+ }
197
+
198
+ const edge = (n: number) =>
199
+ root.query(
200
+ `CREATE type::record('_00_list_ref_user_alice', $n) SET in = _00_query:q, out = game:1, version = $n`,
201
+ { n }
202
+ );
203
+
204
+ /** The client's register read-back, as `registerSelect` sends it. */
205
+ const readBack = async (db: Surreal, id: RecordId<string>) =>
206
+ ((await db.query(queryRowCountSelect(), { in: id })) as [unknown])[0] ?? null;
207
+
208
+ const heartbeatFound = async (db: Surreal, id: RecordId<string>) => {
209
+ const beat = heartbeatBatch([id]);
210
+ const [result] = (await db.query(beat.sql, beat.vars)) as [unknown];
211
+ return !heartbeatRowGone(result);
212
+ };
213
+
214
+ const registerError = async (db: Surreal): Promise<unknown> =>
215
+ db
216
+ .query('RETURN fn::query::register($config)', {
217
+ config: { id: viewId(), surql: 'SELECT * FROM game', params: {}, ttl: '1h' },
218
+ })
219
+ .then(
220
+ () => null,
221
+ (error: unknown) => error
222
+ );
223
+
224
+ it('a socket from before the reset stops receiving edges, a fresh sign-in keeps them', async (ctx) => {
225
+ if (!reachable) ctx.skip();
226
+ await boot(GUARD);
227
+ const [stale] = await alice('signup');
228
+ const staleSeen = await watchEdges(stale);
229
+ await edge(1);
230
+ await expect.poll(() => staleSeen.length).toBe(1);
231
+
232
+ await resetPassword(stale);
233
+ const [fresh] = await alice('signin');
234
+ const freshSeen = await watchEdges(fresh);
235
+ await edge(2);
236
+ await edge(3);
237
+
238
+ await expect.poll(() => freshSeen.length).toBe(2);
239
+ await sleep(200);
240
+ expect(staleSeen.length).toBe(1);
241
+ });
242
+
243
+ it('a socket from before the reset reads none of its user\'s edges, views or rows', async (ctx) => {
244
+ if (!reachable) ctx.skip();
245
+ await boot(GUARD);
246
+ const [stale, staleSid] = await alice('signup');
247
+ await edge(1);
248
+ await root.query(
249
+ `CREATE _00_list_ref:1 SET in = _00_query:q, out = game:1, clientId = 'x', auth_id = 'user:alice', version = 1`
250
+ );
251
+ await resetPassword(stale);
252
+ const [fresh, freshSid] = await alice('signin');
253
+ const q = viewId();
254
+ await root.query(SSP_REGISTER, { id: q, sid: staleSid });
255
+ await root.query(SSP_REGISTER, { id: q, sid: freshSid });
256
+
257
+ for (const table of ['_00_list_ref_user_alice', '_00_list_ref', 'game']) {
258
+ expect(await stale.query(`SELECT VALUE id FROM ${table}`), table).toEqual([[]]);
259
+ expect(((await fresh.query(`SELECT VALUE id FROM ${table}`)) as [unknown[]])[0], table).toHaveLength(1);
260
+ }
261
+ expect(await readBack(stale, q)).toBeNull();
262
+ expect(await heartbeatFound(stale, q)).toBe(false);
263
+ expect(await readBack(fresh, q)).toEqual({ rowCount: 1, state: 'ready' });
264
+ expect(await heartbeatFound(fresh, q)).toBe(true);
265
+ });
266
+
267
+ it('fn::query::register refuses the stale socket as a refused session, and only that one', async (ctx) => {
268
+ if (!reachable) ctx.skip();
269
+ await boot(GUARD);
270
+ const [stale] = await alice('signup');
271
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
272
+
273
+ await resetPassword(stale);
274
+ const [fresh] = await alice('signin');
275
+
276
+ const refused = await registerError(stale);
277
+ expect(isRefusedSessionError(refused), String(refused)).toBe(true);
278
+ expect(isRefusedSessionError(await registerError(fresh))).toBe(false);
279
+ });
280
+
281
+ it('without a sessionGuard every session stays live, as before the guard', async (ctx) => {
282
+ if (!reachable) ctx.skip();
283
+ await boot();
284
+ const [stale, staleSid] = await alice('signup');
285
+ await edge(1);
286
+ const q = viewId();
287
+ await root.query(SSP_REGISTER, { id: q, sid: staleSid });
288
+ await resetPassword(stale);
289
+
290
+ expect(((await stale.query('SELECT VALUE id FROM _00_list_ref_user_alice')) as [unknown[]])[0]).toHaveLength(1);
291
+ expect(await heartbeatFound(stale, q)).toBe(true);
292
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
293
+ });
294
+
295
+ it('a database without the probe row lets every registration through', async (ctx) => {
296
+ if (!reachable) ctx.skip();
297
+ await boot(GUARD);
298
+ const [stale] = await alice('signup');
299
+ await resetPassword(stale);
300
+ await root.query('DELETE _00_session_guard:probe');
301
+
302
+ expect(isRefusedSessionError(await registerError(stale))).toBe(false);
303
+ });
304
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import * as sql from './sql';
4
+ import {
5
+ buildListRefBatchSelect,
6
+ buildListRefSelect,
7
+ buildQueryRowCountBatchSelect,
8
+ buildQueryRowCountSelect,
9
+ buildSubqueryListRefSelect,
10
+ } from '../sync/policy';
11
+
12
+ describe('query sql builders (golden against the previous builders)', () => {
13
+ it('edge and meta selects are byte-identical to the old helpers', () => {
14
+ expect(sql.listRefSelect('_00_list_ref_user_x')).toBe(buildListRefSelect('_00_list_ref_user_x'));
15
+ expect(sql.subqueryListRefSelect('t')).toBe(buildSubqueryListRefSelect('t'));
16
+ expect(sql.queryRowCountSelect()).toBe(buildQueryRowCountSelect());
17
+ expect(sql.listRefBatchSelect('t')).toBe(buildListRefBatchSelect('t'));
18
+ expect(sql.queryRowCountBatchSelect()).toBe(buildQueryRowCountBatchSelect());
19
+ });
20
+ it('reads edges through the graph, byte-identical to spooky_core sql.dart', () => {
21
+ expect(sql.listRefSelect('_00_list_ref_user_u1')).toBe(
22
+ 'SELECT out, version FROM $in->_00_list_ref_user_u1 WHERE parent IS NONE'
23
+ );
24
+ expect(sql.subqueryListRefSelect('_00_list_ref')).toBe(
25
+ 'SELECT out, version FROM $in->_00_list_ref WHERE parent IS NOT NONE'
26
+ );
27
+ expect(sql.listRefBatchSelect('_00_list_ref')).toBe('SELECT in, out, version, parent FROM $ins->_00_list_ref');
28
+ });
29
+ it('composes the single, batch and register requests', () => {
30
+ expect(sql.singleSnapshotSelect('t').split(';\n')).toEqual([
31
+ sql.listRefSelect('t'),
32
+ sql.queryRowCountSelect(),
33
+ sql.subqueryListRefSelect('t'),
34
+ ]);
35
+ expect(sql.batchSnapshotSelect('t')).toBe(`${sql.listRefBatchSelect('t')};\n${sql.queryRowCountBatchSelect()}`);
36
+ expect(sql.registerSelect('t').startsWith('fn::query::register($config);\n')).toBe(true);
37
+ const id = new RecordId('_00_query', 'h');
38
+ expect(sql.registerVars({ id, surql: 's', params: { a: 1 }, ttl: '10m' })).toEqual({
39
+ config: { id, surql: 's', params: { a: 1 }, ttl: '10m' },
40
+ in: id,
41
+ });
42
+ // The owner secret rides beside params, never inside them: params name
43
+ // the computation the SSP shares between views.
44
+ expect(sql.registerVars({ id, surql: 's', params: { a: 1 }, ttl: '10m', owner: 'k' })).toEqual({
45
+ config: { id, surql: 's', params: { a: 1 }, ttl: '10m', owner: 'k' },
46
+ in: id,
47
+ });
48
+ });
49
+ it('the LIVE on a per-user table is unfiltered; FETCH out only when bodies ride inline', () => {
50
+ expect(sql.listRefLive('_00_list_ref_user_x', { inlineBodies: true, sessionIds: ['s1'] })).toEqual({
51
+ sql: 'LIVE SELECT * FROM _00_list_ref_user_x FETCH out',
52
+ });
53
+ expect(sql.listRefLive('_00_list_ref', { inlineBodies: false, sessionIds: [] })).toEqual({
54
+ sql: 'LIVE SELECT * FROM _00_list_ref',
55
+ });
56
+ });
57
+ it("the anon LIVE hears only this client's sockets, current one by session::id()", () => {
58
+ const sids = ['earlier-1', 'earlier-2'];
59
+ const live = sql.listRefLive('_00_list_ref_anon', { inlineBodies: true, sessionIds: sids });
60
+ expect(live.sql).toBe(
61
+ 'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out'
62
+ );
63
+ expect(live.vars).toEqual({ sids });
64
+ // A copy: the caller's list keeps growing after the LIVE is sent.
65
+ expect(live.vars!.sids).not.toBe(sids);
66
+ expect(sql.listRefLive('_00_list_ref_anon', { inlineBodies: false, sessionIds: [] })).toEqual({
67
+ sql: 'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids',
68
+ vars: { sids: [] },
69
+ });
70
+ });
71
+ it('view rows and ids', () => {
72
+ expect(sql.viewRecordId('k')).toEqual(new RecordId('_00_view', 'k'));
73
+ expect(sql.viewRow([['t:1', 1]], [['c:1', 2]], true, 5)).toEqual({ ids: [['t:1', 1]], children: [['c:1', 2]], confirmed: true, updatedAt: 5 });
74
+ expect(sql.readViewRows()).toBe('SELECT * FROM _00_view');
75
+ expect(sql.readLegacyViewRows()).toBe('SELECT * FROM _00_window');
76
+ expect(sql.countViewRows()).toContain('_00_view');
77
+ expect(sql.bodySelect()).toBe('SELECT * FROM $ids');
78
+ });
79
+ it('heartbeat batch answers per index and detects reclaimed rows', () => {
80
+ const a = new RecordId('_00_query', 'a');
81
+ const b = new RecordId('_00_query', 'b');
82
+ expect(sql.heartbeatBatch([a, b])).toEqual({
83
+ sql: 'fn::query::heartbeat($id0);\nfn::query::heartbeat($id1)',
84
+ vars: { id0: a, id1: b },
85
+ });
86
+ expect(sql.heartbeatRowGone([])).toBe(true);
87
+ expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
88
+ expect(sql.heartbeatRowGone(undefined)).toBe(false);
89
+ });
90
+ it('the pagehide beacon names the page sessions and inlines only shapes it minted', () => {
91
+ const a = new RecordId('_00_query', 'a'.repeat(64));
92
+ const b = new RecordId('_00_query', 'b'.repeat(64));
93
+ const s1 = 'de81a067-f4bc-4351-a2a3-1ecf06b6305a';
94
+ const s2 = '8A14ED5A-32B5-4B4E-973D-D2B88860932F';
95
+ expect(sql.leaveViewsBeacon([a, b], [s1, s2])).toBe(
96
+ `FOR $id IN [${a}, ${b}] { LET $_left = fn::query::leave($id, ['${s1}', '${s2}']); };`
97
+ );
98
+ const digits = new RecordId('_00_query', '1'.repeat(64));
99
+ expect(sql.leaveViewsBeacon([digits], [s1])).toBe(`FOR $id IN [${digits}] { LET $_left = fn::query::leave($id, ['${s1}']); };`);
100
+ // Anything else is dropped rather than interpolated.
101
+ expect(sql.leaveViewsBeacon([a, new RecordId('_00_query', "x']; DELETE user; --"), 'user:1'], [s1, "x'; DELETE user; --"])).toBe(
102
+ `FOR $id IN [${a}] { LET $_left = fn::query::leave($id, ['${s1}']); };`
103
+ );
104
+ // No session ids: the server could not tell this page's entries from a reload's, so send nothing.
105
+ expect(sql.leaveViewsBeacon([a], [])).toBeNull();
106
+ expect(sql.leaveViewsBeacon([], [s1])).toBeNull();
107
+ });
108
+ it('upsertBodiesTx MERGEs each body inside one transaction', () => {
109
+ const { query, vars } = sql.upsertBodiesTx([
110
+ { id: 'thing:1', content: { a: 1 } },
111
+ { id: 'thing:2', content: { b: 2 } },
112
+ ]);
113
+ expect(query.sql).toBe(
114
+ 'BEGIN TRANSACTION;\nUPSERT ONLY $id0 MERGE $content0;UPSERT ONLY $id1 MERGE $content1;\nCOMMIT TRANSACTION;'
115
+ );
116
+ expect(vars).toEqual({ id0: 'thing:1', content0: { a: 1 }, id1: 'thing:2', content1: { b: 2 } });
117
+ });
118
+ });
@@ -0,0 +1,174 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { RecordVersionArray } from '../types';
3
+ import type { Vars } from '../kernel/effects';
4
+ import { surql } from '../utils/surql';
5
+ import type { SealedQuery } from '../utils/surql';
6
+ import { ANON_LIST_REF_TABLE } from '../modules/ref-tables';
7
+
8
+ /** Every SurrealQL statement the query side sends, as pure builders. */
9
+
10
+ export const VIEW_TABLE = '_00_view';
11
+ export const LEGACY_VIEW_TABLE = '_00_window';
12
+
13
+ export const viewRecordId = (viewKey: string): RecordId<string> => new RecordId(VIEW_TABLE, viewKey);
14
+
15
+ /**
16
+ * The durable `_00_view` row: the confirmed membership plus the subquery
17
+ * children, which the GC must keep and a cold scan may paint as much as the
18
+ * members.
19
+ */
20
+ export const viewRow = (
21
+ ids: RecordVersionArray,
22
+ children: RecordVersionArray,
23
+ confirmed: boolean,
24
+ now: number
25
+ ): Record<string, unknown> => ({
26
+ ids,
27
+ children,
28
+ confirmed,
29
+ updatedAt: now,
30
+ });
31
+
32
+ /** Every `_00_view` row: the durable view index and the GC read it. */
33
+ export const readViewRows = (): string => `SELECT * FROM ${VIEW_TABLE}`;
34
+
35
+ /** Copy every legacy `_00_window` row into `_00_view` once. */
36
+ export const readLegacyViewRows = (): string => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
37
+ export const countViewRows = (): string => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
38
+
39
+ // Edges are read through the graph (`$in->table`): the `_00_list_ref*` tables
40
+ // have no index on `in`, so `WHERE in = $in` scanned the whole table, which on
41
+ // the shared `_00_list_ref_anon` is every signed-out visitor's edges.
42
+ export const listRefSelect = (table: string): string =>
43
+ `SELECT out, version FROM $in->${table} WHERE parent IS NONE`;
44
+ export const subqueryListRefSelect = (table: string): string =>
45
+ `SELECT out, version FROM $in->${table} WHERE parent IS NOT NONE`;
46
+ export const queryRowCountSelect = (): string => 'SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in';
47
+ export const listRefBatchSelect = (table: string): string =>
48
+ `SELECT in, out, version, parent FROM $ins->${table}`;
49
+ export const queryRowCountBatchSelect = (): string => 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins';
50
+
51
+ /** The single-query read: edges, meta, subquery children in one request. */
52
+ export const singleSnapshotSelect = (table: string): string =>
53
+ `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
54
+ /** The many-query read: all edges + all metas in one request. */
55
+ export const batchSnapshotSelect = (table: string): string =>
56
+ `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
57
+
58
+ /**
59
+ * The session's one LIVE subscription on its `_00_list_ref` table.
60
+ *
61
+ * `inlineBodies` adds `FETCH out`, which resolves the edge's target into the
62
+ * notification so the body arrives with the doorbell.
63
+ *
64
+ * On the anon table it is filtered to this client's own edges. Every
65
+ * signed-out session shares that table, so unfiltered each one received (and
66
+ * the server fetched a body for) every anonymous edge change of every other
67
+ * session, and the client then dropped all but its own. An edge carries the
68
+ * `session::id()` of whichever socket last registered its view, which is a
69
+ * socket of this client but not necessarily the current one: a view stays
70
+ * registered across a reconnect, and a reload keeps its salt and its views,
71
+ * so their older edges still name an earlier socket. `sessionIds` are those
72
+ * earlier sockets. The current one is matched by `session::id()` itself,
73
+ * which SurrealDB evaluates in the subscriber's own session, so the filter
74
+ * holds before the client has learned its new id.
75
+ *
76
+ * The cast is load-bearing: `session::id()` is a uuid, `clientId` a string,
77
+ * and without it nothing matches. Per-user tables need no filter.
78
+ */
79
+ export function listRefLive(
80
+ table: string,
81
+ opts: { inlineBodies: boolean; sessionIds: ReadonlyArray<string> }
82
+ ): { sql: string; vars?: Vars } {
83
+ const join = opts.inlineBodies ? ' FETCH out' : '';
84
+ if (table !== ANON_LIST_REF_TABLE) return { sql: `LIVE SELECT * FROM ${table}${join}` };
85
+ return {
86
+ sql: `LIVE SELECT * FROM ${table} WHERE clientId = <string>session::id() OR clientId IN $sids${join}`,
87
+ vars: { sids: [...opts.sessionIds] },
88
+ };
89
+ }
90
+
91
+ export interface RegisterPayload {
92
+ id: RecordId<string>;
93
+ surql: string;
94
+ params: Record<string, unknown>;
95
+ ttl: string;
96
+ /**
97
+ * The registering page's secret. `fn::query::register` forwards only its
98
+ * hash, and the SSP lets a registration onto an existing anonymous view
99
+ * only with the hash that view was registered with: every signed-out
100
+ * visitor is `anon`, and anyone can read an anonymous view's id. Kept out
101
+ * of `params`, which name the computation the SSP shares between views.
102
+ */
103
+ owner?: string;
104
+ }
105
+
106
+ /** Register + read back edges/meta/children in ONE request. */
107
+ export const registerSelect = (table: string): string =>
108
+ `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
109
+ export const registerVars = (payload: RegisterPayload): Vars => ({ config: payload, in: payload.id });
110
+
111
+ /** One heartbeat statement per view id; result index i answers id i. */
112
+ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: string; vars: Vars } {
113
+ const vars: Vars = {};
114
+ const stmts = ids.map((id, i) => {
115
+ vars[`id${i}`] = id;
116
+ return `fn::query::heartbeat($id${i})`;
117
+ });
118
+ return { sql: stmts.join(';\n'), vars };
119
+ }
120
+
121
+ /**
122
+ * Hand back view ids this page no longer answers to. Per id,
123
+ * `fn::query::unsubscribe` drops this socket's entry and deletes the row when
124
+ * nobody else watches it; `fn::query::leave` then drops the entries of this
125
+ * page's earlier sockets (a reconnect re-registered under a new session id)
126
+ * and pulls the expiry in when none remain. Result index 2i answers the
127
+ * unsubscribe of id i, 2i + 1 its leave.
128
+ */
129
+ export function releaseBatch(ids: ReadonlyArray<RecordId<string>>, sessionIds: ReadonlyArray<string>): { sql: string; vars: Vars } {
130
+ const vars: Vars = { sids: [...sessionIds] };
131
+ const stmts = ids.flatMap((id, i) => {
132
+ vars[`id${i}`] = id;
133
+ return [`fn::query::unsubscribe($id${i})`, `fn::query::leave($id${i}, $sids)`];
134
+ });
135
+ return { sql: stmts.join(';\n'), vars };
136
+ }
137
+
138
+ /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
139
+ export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
140
+
141
+ // The SDK escapes an all-digit key as `⟨…⟩`; both forms parse back to the same id.
142
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
143
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
144
+
145
+ /**
146
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
147
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
148
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
149
+ * interpolated. `null` when there is nothing the server could act on: with no
150
+ * session ids it could not tell this page's entries from anyone else's.
151
+ */
152
+ export function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null {
153
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
154
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
155
+ if (list.length === 0 || sids.length === 0) return null;
156
+ const sidList = sids.map((sid) => `'${sid}'`).join(', ');
157
+ return `FOR $id IN [${list.join(', ')}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
158
+ }
159
+
160
+ export const bodySelect = (): string => 'SELECT * FROM $ids';
161
+
162
+ /** Local transaction that MERGEs many fetched bodies. */
163
+ export function upsertBodiesTx(records: ReadonlyArray<{ id: unknown; content: Record<string, unknown> }>): {
164
+ query: SealedQuery<unknown>;
165
+ vars: Vars;
166
+ } {
167
+ const vars: Vars = {};
168
+ const stmts = records.map((r, i) => {
169
+ vars[`id${i}`] = r.id;
170
+ vars[`content${i}`] = r.content;
171
+ return surql.upsertMerge(`id${i}`, `content${i}`);
172
+ });
173
+ return { query: surql.seal<void>(surql.tx(stmts)) as SealedQuery<unknown>, vars };
174
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildWindowMaterialization } from './window-query';
3
+
4
+ describe('scanner edge cases', () => {
5
+ it('skips escaped quotes inside strings and ignores clauses in parens', () => {
6
+ const q = "SELECT * FROM t WHERE a = 'it\\'s (FROM)' AND b IN (SELECT id FROM u LIMIT 1 START 1) LIMIT 5 START 5";
7
+ expect(buildWindowMaterialization(q)).toEqual({ query: 'SELECT * FROM $__win' });
8
+ });
9
+ it('requires whitespace inside ORDER BY and a number after START', () => {
10
+ expect(buildWindowMaterialization('SELECT * FROM t ORDERBY a LIMIT 5 START 5')).toEqual({ query: 'SELECT * FROM $__win' });
11
+ expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START $n')).toBeNull();
12
+ expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START')).toBeNull();
13
+ });
14
+ it('matches a keyword at the very start and at the end of the string', () => {
15
+ expect(buildWindowMaterialization('FROM t LIMIT 5 START 5')).toEqual({ query: ' FROM (SELECT * FROM $__win)' });
16
+ expect(buildWindowMaterialization('SELECT * FROM t ORDER BY a LIMIT 5 START 5 ORDER')).toEqual({
17
+ query: 'SELECT * FROM (SELECT * FROM $__win) ORDER BY a',
18
+ });
19
+ });
20
+ });
21
+
22
+ describe('buildWindowMaterialization', () => {
23
+ it('rewrites the game-list window query (START 30) to select the id-set, keeping ORDER BY', () => {
24
+ const surql =
25
+ 'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 30;';
26
+ const r = buildWindowMaterialization(surql);
27
+ expect(r).not.toBeNull();
28
+ expect(r!.query).toBe('SELECT * FROM (SELECT * FROM $__win) ORDER BY sort_index asc, date desc');
29
+ });
30
+
31
+ it('returns null for START 0 (offset-free windows still work via the normal re-query)', () => {
32
+ const surql =
33
+ 'SELECT * FROM game WHERE database = $database ORDER BY sort_index asc, date desc LIMIT 30 START 0;';
34
+ expect(buildWindowMaterialization(surql)).toBeNull();
35
+ });
36
+
37
+ it('returns null when there is no START clause', () => {
38
+ expect(
39
+ buildWindowMaterialization('SELECT * FROM game WHERE database = $database LIMIT 30;')
40
+ ).toBeNull();
41
+ expect(buildWindowMaterialization('SELECT * FROM game;')).toBeNull();
42
+ });
43
+
44
+ it('preserves a custom projection', () => {
45
+ const surql = 'SELECT id, white, black FROM game ORDER BY date desc LIMIT 30 START 60;';
46
+ expect(buildWindowMaterialization(surql)!.query).toBe(
47
+ 'SELECT id, white, black FROM (SELECT * FROM $__win) ORDER BY date desc'
48
+ );
49
+ });
50
+
51
+ it('omits ORDER BY when the query had none', () => {
52
+ const surql = 'SELECT * FROM game LIMIT 30 START 30;';
53
+ expect(buildWindowMaterialization(surql)!.query).toBe('SELECT * FROM $__win');
54
+ });
55
+
56
+ it('ignores FROM/ORDER BY/LIMIT/START inside subqueries (paren-aware)', () => {
57
+ const surql =
58
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments ' +
59
+ 'FROM game ORDER BY sort_index asc LIMIT 30 START 90;';
60
+ expect(buildWindowMaterialization(surql)!.query).toBe(
61
+ 'SELECT *, (SELECT * FROM comment WHERE game = $parent.id ORDER BY created_at desc LIMIT 5) AS comments FROM (SELECT * FROM $__win) ORDER BY sort_index asc'
62
+ );
63
+ });
64
+
65
+ it('does not treat a START inside a string literal as the offset', () => {
66
+ const surql = "SELECT * FROM game WHERE note = 'LIMIT 30 START 30' ORDER BY date desc LIMIT 30 START 30;";
67
+ const r = buildWindowMaterialization(surql);
68
+ expect(r!.query).toBe('SELECT * FROM (SELECT * FROM $__win) ORDER BY date desc');
69
+ });
70
+ });