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

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 (261) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15236 -2467
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +733 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +365 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1821 -38
  19. package/dist/worker-edges.js +470 -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 +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +180 -0
  32. package/src/boot/boot.saga.ts +142 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +60 -0
  46. package/src/client/services.ts +418 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +163 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +501 -0
  62. package/src/live/index.ts +1039 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +169 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +93 -0
  90. package/src/modules/ref-tables.ts +91 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +66 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/anon-query-isolation.integration.test.ts +337 -0
  110. package/src/query/env.test.ts +23 -0
  111. package/src/query/env.ts +56 -0
  112. package/src/query/fetch.saga.test.ts +199 -0
  113. package/src/query/fetch.saga.ts +120 -0
  114. package/src/query/hash.test.ts +14 -0
  115. package/src/query/hash.ts +18 -0
  116. package/src/query/id-set-plan.test.ts +132 -0
  117. package/src/query/lifecycle.saga.test.ts +274 -0
  118. package/src/query/lifecycle.saga.ts +159 -0
  119. package/src/query/list-ref-live.integration.test.ts +203 -0
  120. package/src/query/materialize.saga.test.ts +115 -0
  121. package/src/query/materialize.saga.ts +84 -0
  122. package/src/query/materialize.test.ts +58 -0
  123. package/src/query/materialize.ts +57 -0
  124. package/src/query/membership.saga.test.ts +350 -0
  125. package/src/query/membership.saga.ts +278 -0
  126. package/src/query/membership.test.ts +118 -0
  127. package/src/query/membership.ts +201 -0
  128. package/src/query/register.saga.test.ts +311 -0
  129. package/src/query/register.saga.ts +229 -0
  130. package/src/query/render-set.test.ts +47 -0
  131. package/src/query/render-set.ts +71 -0
  132. package/src/query/sql.test.ts +112 -0
  133. package/src/query/sql.ts +149 -0
  134. package/src/query/window-query.test.ts +70 -0
  135. package/src/query/window-query.ts +195 -0
  136. package/src/services/blobs/blob-cache.test.ts +359 -0
  137. package/src/services/blobs/blob-cache.ts +603 -0
  138. package/src/services/blobs/blob-manifest.ts +227 -0
  139. package/src/services/blobs/blob-store.test.ts +77 -0
  140. package/src/services/blobs/blob-store.ts +359 -0
  141. package/src/services/blobs/blob.fixture.ts +90 -0
  142. package/src/services/blobs/index.ts +70 -0
  143. package/src/services/database/cache-engine.ts +199 -0
  144. package/src/services/database/connection-supervisor.test.ts +318 -0
  145. package/src/services/database/connection-supervisor.ts +422 -0
  146. package/src/services/database/database.query-timeout.test.ts +140 -0
  147. package/src/services/database/database.responses.test.ts +43 -0
  148. package/src/services/database/database.ts +146 -61
  149. package/src/services/database/engine-factory.ts +33 -0
  150. package/src/services/database/errors.ts +34 -0
  151. package/src/services/database/events/index.ts +2 -1
  152. package/src/services/database/index.ts +7 -0
  153. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  154. package/src/services/database/local-migrator.integration.test.ts +110 -0
  155. package/src/services/database/local-migrator.ts +59 -16
  156. package/src/services/database/local.test.ts +64 -0
  157. package/src/services/database/local.ts +487 -63
  158. package/src/services/database/plan-render.test.ts +159 -0
  159. package/src/services/database/plan-render.ts +108 -0
  160. package/src/services/database/relation-resolver.test.ts +484 -0
  161. package/src/services/database/relation-resolver.ts +0 -0
  162. package/src/services/database/remote.ts +273 -6
  163. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  164. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  165. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  166. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  167. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  168. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  169. package/src/services/database/sqlite-lock-verify.ts +45 -0
  170. package/src/services/database/sqlite-open.test.ts +150 -0
  171. package/src/services/database/sqlite-open.ts +164 -0
  172. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  173. package/src/services/database/sqlite-plan-sql.ts +182 -0
  174. package/src/services/database/sqlite-projection.test.ts +121 -0
  175. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  176. package/src/services/database/sqlite-select.test.ts +261 -0
  177. package/src/services/database/sqlite-select.ts +131 -0
  178. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  179. package/src/services/database/sqlite-transport.ts +224 -0
  180. package/src/services/database/sqlite-worker.ts +437 -0
  181. package/src/services/database/surql-translate.ts +416 -0
  182. package/src/services/database/surreal-cache-engine.ts +161 -0
  183. package/src/services/logger/index.test.ts +132 -0
  184. package/src/services/logger/index.ts +189 -7
  185. package/src/services/persistence/localstorage.ts +2 -2
  186. package/src/services/persistence/resilient.ts +10 -3
  187. package/src/services/persistence/surrealdb.ts +4 -4
  188. package/src/services/stream-processor/index.ts +783 -68
  189. package/src/services/stream-processor/permissions.test.ts +47 -0
  190. package/src/services/stream-processor/permissions.ts +53 -0
  191. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  192. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  193. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  194. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  195. package/src/services/stream-processor/wasm-types.ts +58 -2
  196. package/src/services/tabs/broker-client.ts +283 -0
  197. package/src/services/tabs/broker.test.ts +327 -0
  198. package/src/services/tabs/coordinator.test.ts +386 -0
  199. package/src/services/tabs/coordinator.ts +638 -0
  200. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  201. package/src/services/tabs/leader-locks.ts +75 -0
  202. package/src/services/tabs/protocol.ts +262 -0
  203. package/src/services/tabs/support.ts +36 -0
  204. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  205. package/src/sp00ky.ts +5 -395
  206. package/src/state/client-state.ts +200 -0
  207. package/src/state/lifecycle.test.ts +141 -0
  208. package/src/state/lifecycle.ts +99 -0
  209. package/src/state/reducers.test.ts +304 -0
  210. package/src/state/reducers.ts +526 -0
  211. package/src/state/selectors.test.ts +175 -0
  212. package/src/state/selectors.ts +222 -0
  213. package/src/sw/index.test.ts +582 -0
  214. package/src/sw/index.ts +607 -0
  215. package/src/sw/store.ts +96 -0
  216. package/src/sync/connection.saga.test.ts +97 -0
  217. package/src/sync/connection.saga.ts +89 -0
  218. package/src/sync/live.saga.test.ts +178 -0
  219. package/src/sync/live.saga.ts +107 -0
  220. package/src/sync/policy.health.test.ts +56 -0
  221. package/src/sync/policy.test.ts +663 -0
  222. package/src/sync/policy.ts +515 -0
  223. package/src/sync/poll.saga.test.ts +51 -0
  224. package/src/sync/poll.saga.ts +36 -0
  225. package/src/sync/tabs.saga.test.ts +78 -0
  226. package/src/sync/tabs.saga.ts +96 -0
  227. package/src/testing/adapters.ts +133 -0
  228. package/src/testing/build.ts +63 -0
  229. package/src/testing/fake-services.ts +48 -0
  230. package/src/testing/run-pure.test.ts +92 -0
  231. package/src/testing/run-pure.ts +128 -0
  232. package/src/testing/services.ts +17 -0
  233. package/src/types.ts +624 -10
  234. package/src/utils/blurhash.ts +90 -0
  235. package/src/utils/edges.ts +39 -0
  236. package/src/utils/error-classification.test.ts +54 -0
  237. package/src/utils/error-classification.ts +25 -0
  238. package/src/utils/index.ts +78 -12
  239. package/src/utils/parser.test.ts +67 -102
  240. package/src/utils/parser.ts +64 -2
  241. package/src/utils/semver.test.ts +32 -0
  242. package/src/utils/semver.ts +30 -0
  243. package/src/utils/sha256.ts +15 -0
  244. package/src/utils/surql.ts +30 -18
  245. package/src/utils/utils.test.ts +3 -2
  246. package/tsdown.config.ts +117 -3
  247. package/vitest.config.ts +48 -0
  248. package/src/modules/cache/index.ts +0 -241
  249. package/src/modules/cache/types.ts +0 -19
  250. package/src/modules/data/data.test.ts +0 -58
  251. package/src/modules/data/index.ts +0 -879
  252. package/src/modules/sync/engine.ts +0 -173
  253. package/src/modules/sync/events/index.ts +0 -77
  254. package/src/modules/sync/index.ts +0 -3
  255. package/src/modules/sync/queue/index.ts +0 -2
  256. package/src/modules/sync/queue/queue-down.ts +0 -89
  257. package/src/modules/sync/queue/queue-up.ts +0 -223
  258. package/src/modules/sync/scheduler.ts +0 -84
  259. package/src/modules/sync/sync.ts +0 -421
  260. package/src/modules/sync/utils.test.ts +0 -311
  261. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,229 @@
1
+ import type { QueryPlan } from '@spooky-sync/query-builder';
2
+ import { RecordId } from 'surrealdb';
3
+ import type { QueryHash, QueryTimeToLive } from '../types';
4
+ import type { Saga } from '../kernel/saga';
5
+ import type { RegisterResult, StatementResult } from '../kernel/effects';
6
+ import { fx } from '../kernel/effects';
7
+ import { AUTH_READY_MAX_ATTEMPTS, AUTH_READY_RETRY_MS, REGISTER_MAX_RETRIES, backoffMs } from '../kernel/constants';
8
+ import type { ClientState, QueryEntry } from '../state/client-state';
9
+ import { emptyTelemetry } from '../state/client-state';
10
+ import { seedLifecycle } from '../state/lifecycle';
11
+ import * as R from '../state/reducers';
12
+ import { desiredRegistrations } from '../state/selectors';
13
+ import { parseDuration } from '../utils/index';
14
+ import type { SagaEnv } from './env';
15
+ import { listRefTable } from './env';
16
+ import { queryHashInput, viewKeyInput } from './hash';
17
+ import { FAILED, isResolvedBefore, parseViewRow, snapshotFromSingle } from './membership';
18
+ import { applyMembership, applySubqueryChildren, markMembershipDirty } from './membership.saga';
19
+ import * as sql from './sql';
20
+
21
+ export interface RegisterInput {
22
+ tableName: string;
23
+ surql: string;
24
+ params: Record<string, unknown>;
25
+ ttl: QueryTimeToLive;
26
+ plan?: QueryPlan;
27
+ }
28
+
29
+ /**
30
+ * Register a query locally: compute its keys, read the durable `_00_view`
31
+ * row, build the SSP local view and publish the entry. Returns as soon as the
32
+ * entry exists (the first paint comes from `materialize`); the remote
33
+ * registration is dispatched, never awaited.
34
+ */
35
+ export function* registerLocal(env: SagaEnv, input: RegisterInput): Saga<QueryHash> {
36
+ const sessionId = (yield fx.state.read((s) => s.sessionId)) as string | null;
37
+ const hash = (yield fx.hash(queryHashInput(input, sessionId))) as string;
38
+ const status = (yield fx.state.read((s) => (s.queries.has(hash) ? 'active' : s.registering.has(hash) ? 'pending' : 'new'))) as
39
+ | 'active'
40
+ | 'pending'
41
+ | 'new';
42
+ if (status === 'active') return hash;
43
+ if (status === 'pending') {
44
+ yield fx.state.wait((s) => !s.registering.has(hash));
45
+ return hash;
46
+ }
47
+ yield fx.state.update(R.beginRegistering(hash));
48
+ try {
49
+ const viewKey = (yield fx.hash(viewKeyInput(input))) as string;
50
+ let viewRow: unknown = null;
51
+ try {
52
+ viewRow = yield fx.local.getById(sql.VIEW_TABLE, sql.viewRecordId(viewKey));
53
+ } catch {
54
+ viewRow = null;
55
+ }
56
+ const view = parseViewRow(viewRow);
57
+ const now = (yield fx.now()) as number;
58
+ const reg = (yield fx.ssp.register({
59
+ queryHash: hash,
60
+ surql: input.surql,
61
+ params: input.params,
62
+ ttl: input.ttl,
63
+ tableName: input.tableName,
64
+ })) as RegisterResult;
65
+ const entry: QueryEntry = {
66
+ def: {
67
+ id: new RecordId('_00_query', hash),
68
+ hash,
69
+ viewKey,
70
+ surql: input.surql,
71
+ params: input.params,
72
+ plan: input.plan,
73
+ ttl: input.ttl,
74
+ ttlMs: parseDuration(input.ttl),
75
+ tableName: input.tableName,
76
+ createdAt: now,
77
+ },
78
+ lifecycle: seedLifecycle(isResolvedBefore(view)),
79
+ remoteArray: view?.ids ?? [],
80
+ localArray: reg.localArray,
81
+ subqueryRemoteArray: [],
82
+ records: [],
83
+ serverState: null,
84
+ subscribers: 0,
85
+ lastSubscriberLeftAt: now,
86
+ lastHeartbeatAt: null,
87
+ lastPolledAt: null,
88
+ registerAttempts: 0,
89
+ viewLostCount: 0,
90
+ viewLostRetryAt: null,
91
+ telemetry: { ...emptyTelemetry(), registrationTimings: reg.timings },
92
+ };
93
+ yield fx.state.update(R.putQuery(entry));
94
+ yield fx.dispatch({ type: 'EnsureRegistered' });
95
+ return hash;
96
+ } finally {
97
+ yield fx.state.update(R.endRegistering(hash));
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Make the remote match the desired set: start a registration for every
103
+ * query that has none. Idempotent. With `requireAuth` (after a reconnect or
104
+ * while degraded) it first waits for `$auth.id` to be visible on the socket:
105
+ * a register issued before that stamps the view with an empty identity.
106
+ */
107
+ export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; attempt?: number } = {}): Saga<void> {
108
+ const [hashes, userId] = (yield fx.state.read((s) => [desiredRegistrations(s), s.userId])) as [QueryHash[], string | null];
109
+ if (hashes.length === 0) return;
110
+ if (opts.requireAuth && userId !== null) {
111
+ let authed = false;
112
+ try {
113
+ const res = (yield fx.remote.query('RETURN $auth.id', undefined, env.remoteTimeoutMs)) as StatementResult[];
114
+ authed = res[0]?.status === 'OK' && res[0].result != null;
115
+ } catch {
116
+ authed = false;
117
+ }
118
+ if (!authed) {
119
+ const attempt = (opts.attempt ?? 0) + 1;
120
+ if (attempt >= AUTH_READY_MAX_ATTEMPTS) {
121
+ yield fx.emit({ type: 'log', level: 'warn', message: 'auth identity never became visible; not registering', data: { attempt } });
122
+ return;
123
+ }
124
+ yield fx.timer.set('ensure-registered', AUTH_READY_RETRY_MS, { type: 'EnsureRegistered', requireAuth: true, attempt });
125
+ return;
126
+ }
127
+ }
128
+ for (const hash of hashes) yield fx.dispatch({ type: 'RegisterRemote', hash });
129
+ }
130
+
131
+ const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
132
+
133
+ /**
134
+ * The SSP answers a registration whose shape is not on the query allowlist
135
+ * with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
136
+ * `fn::query::register` statement error carrying SurrealDB's http error text
137
+ * (status reason and/or body), and no retry can change the answer.
138
+ */
139
+ export const isNotAllowlisted = (error: unknown): boolean =>
140
+ /not_allowlisted|\b403\b|forbidden/i.test(String((error as { message?: unknown })?.message ?? error));
141
+
142
+ /**
143
+ * One remote registration: `fn::query::register` plus the edge/meta/children
144
+ * read in ONE request, then membership application. Concurrent with every
145
+ * other registration; retried on its own backoff; stops when the entry is
146
+ * gone.
147
+ */
148
+ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
149
+ const state = (yield fx.state.read((s) => s)) as ClientState;
150
+ const entry = state.queries.get(hash);
151
+ if (!entry) return;
152
+ const { remote } = entry.lifecycle;
153
+ if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
154
+ // A query that already shows rows (cached or live) only re-verifies them
155
+ // here: the cycle holds `fetching` but is not traffic the user waits on.
156
+ const background = entry.lifecycle.phase !== 'cold';
157
+ yield fx.state.update(
158
+ R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin', background }))
159
+ );
160
+ try {
161
+ const table = listRefTable(env, state);
162
+ const results = (yield fx.remote.query(
163
+ sql.registerSelect(table),
164
+ sql.registerVars({ id: entry.def.id, surql: entry.def.surql, params: { ...entry.def.params }, ttl: entry.def.ttl }),
165
+ env.remoteTimeoutMs
166
+ )) as StatementResult[];
167
+ const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
168
+ if (!stillThere) return;
169
+ const register = stmt(results, 0);
170
+ if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
171
+ const answer = (r: StatementResult | undefined): unknown => (r?.status === 'OK' ? r.result : FAILED);
172
+ const snap = snapshotFromSingle(
173
+ answer(stmt(results, 1)) as never,
174
+ answer(stmt(results, 2)) as never,
175
+ answer(stmt(results, 3)) as never
176
+ );
177
+ if (!snap) {
178
+ // Registered, but the read-back did not answer. That says nothing about
179
+ // the row, so read membership again rather than guess: a guess of
180
+ // "gone" re-registered the query, and under load the next read-back
181
+ // failed the same way.
182
+ yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
183
+ yield fx.emit({ type: 'log', level: 'debug', message: 'registration read-back did not answer; re-reading membership', data: { hash } });
184
+ yield* markMembershipDirty([hash]);
185
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error: 'registration read-back failed' });
186
+ return;
187
+ }
188
+ const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
189
+ if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') {
190
+ yield fx.state.update(R.compose(R.markMembershipDirty([hash]), R.setMembershipReread(hash, 1)));
191
+ yield fx.timer.set('membership', 150, { type: 'ReadDirtyMembership' });
192
+ }
193
+ yield* applySubqueryChildren(hash, snap.subquery);
194
+ yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
195
+ yield fx.dispatch({ type: 'SyncOutcome', ok: true });
196
+ } catch (error) {
197
+ const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
198
+ if (!current) return;
199
+ if (isNotAllowlisted(error)) {
200
+ yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
201
+ yield fx.emit({
202
+ type: 'log',
203
+ level: 'error',
204
+ message: 'query refused: not allowlisted',
205
+ data: {
206
+ hash,
207
+ table: entry.def.tableName,
208
+ surql: entry.def.surql,
209
+ params: Object.keys(entry.def.params),
210
+ hint: 'Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev).',
211
+ },
212
+ });
213
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
214
+ return;
215
+ }
216
+ const attempts = current.registerAttempts + 1;
217
+ yield fx.state.update(R.bumpRegisterAttempts(hash));
218
+ yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
219
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
220
+ if (attempts >= REGISTER_MAX_RETRIES) {
221
+ yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
222
+ } else {
223
+ yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
224
+ }
225
+ } finally {
226
+ const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
227
+ if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end', background }));
228
+ }
229
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildRenderIds, paintsCold, resolveMembership } from './render-set';
3
+
4
+ const ov = (writes: string[] = [], deletes: string[] = []) => ({ writes: new Set(writes), deletes: new Set(deletes) });
5
+
6
+ describe('resolveMembership', () => {
7
+ it('cold scans, cold window uses the local view, anything else uses membership', () => {
8
+ const remoteArray: [string, number][] = [['t:1', 1]];
9
+ const localArray: [string, number][] = [['t:2', 1]];
10
+ expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: false })).toBeNull();
11
+ expect(resolveMembership({ phase: 'cold', remoteArray, localArray, isWindow: true })).toBe(localArray);
12
+ expect(resolveMembership({ phase: 'cached', remoteArray, localArray, isWindow: false })).toBe(remoteArray);
13
+ expect(resolveMembership({ phase: 'view-lost', remoteArray, localArray, isWindow: true })).toBe(remoteArray);
14
+ });
15
+ });
16
+
17
+ describe('buildRenderIds', () => {
18
+ const opts = { hasExplicitOrder: false, isWindow: false };
19
+ it('membership minus deletes, sorted', () => {
20
+ expect(buildRenderIds([['t:b', 1], ['t:a', 1], ['t:c', 1]], [], ov([], ['t:c']), opts)).toEqual(['t:a', 't:b']);
21
+ });
22
+ it('adds pending writes the local view admits, never twice, never deleted', () => {
23
+ const ids = buildRenderIds(
24
+ [['t:1', 1]],
25
+ [['t:1', 1], ['t:2', 1], ['t:3', 1], ['t:4', 1]],
26
+ ov(['t:1', 't:2', 't:3', 't:9'], ['t:3']),
27
+ opts
28
+ );
29
+ expect(ids).toEqual(['t:1', 't:2']);
30
+ });
31
+ it('keeps server order for windows and explicit orderBy; dedupes membership', () => {
32
+ const m: [string, number][] = [['t:b', 1], ['t:a', 1], ['t:b', 1]];
33
+ expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: true, isWindow: false })).toEqual(['t:b', 't:a']);
34
+ expect(buildRenderIds(m, [], ov(), { hasExplicitOrder: false, isWindow: true })).toEqual(['t:b', 't:a']);
35
+ });
36
+ });
37
+
38
+ describe('paintsCold', () => {
39
+ it('only vouched bodies, optimistic writes and bookkeeping paint before the server answers', () => {
40
+ const vouched = new Set(['t:1']);
41
+ expect(paintsCold('t:1', vouched, ov())).toBe(true);
42
+ expect(paintsCold('t:2', vouched, ov(['t:2']))).toBe(true);
43
+ expect(paintsCold('_00_user_feature:f', vouched, ov())).toBe(true);
44
+ expect(paintsCold('t:3', vouched, ov([], ['t:3']))).toBe(false);
45
+ expect(paintsCold('t:3', vouched, ov())).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,71 @@
1
+ import type { RecordVersionArray } from '../types';
2
+ import type { QueryPhase } from '../state/lifecycle';
3
+ import type { Overlay } from '../state/selectors';
4
+
5
+ export interface RenderInput {
6
+ phase: QueryPhase;
7
+ remoteArray: RecordVersionArray;
8
+ localArray: RecordVersionArray;
9
+ isWindow: boolean;
10
+ }
11
+
12
+ /**
13
+ * The id-set a query renders from, or `null` when it must fall back to a
14
+ * local predicate scan (a cold query that was never resolved on this device).
15
+ * A cold window has no usable scan (re-applying `START m` against the shared
16
+ * store returns the wrong rows), so it renders the SSP's local window instead.
17
+ */
18
+ export function resolveMembership(input: RenderInput): RecordVersionArray | null {
19
+ if (input.phase !== 'cold') return input.remoteArray;
20
+ if (input.isWindow) return input.localArray;
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Whether a body may paint before the server has answered (`cold`). One no
26
+ * durable view vouches for and no optimistic write touches is an orphan:
27
+ * deleted upstream while no view watched it, or dropped from every view since.
28
+ * The predicate scan would otherwise resurrect it until the server answers,
29
+ * and indefinitely offline.
30
+ */
31
+ export function paintsCold(id: string, vouched: ReadonlySet<string>, overlay: Overlay): boolean {
32
+ return id.startsWith('_00_') || vouched.has(id) || overlay.writes.has(id);
33
+ }
34
+
35
+ export interface RenderOptions {
36
+ hasExplicitOrder: boolean;
37
+ isWindow: boolean;
38
+ }
39
+
40
+ /**
41
+ * render = (membership ∪ (writes ∩ localView)) − deletes
42
+ *
43
+ * `writes`/`deletes` are the outbox overlay (pending and acked items). The
44
+ * middle term keeps optimistic writes visible without re-admitting stale rows:
45
+ * `localView` (the SSP's local id-set) says whether the written row matches
46
+ * the query per local truth. Sorted unless the query orders itself or is a
47
+ * window (whose id order is the slice order).
48
+ */
49
+ export function buildRenderIds(
50
+ membership: RecordVersionArray,
51
+ localView: RecordVersionArray,
52
+ overlay: Overlay,
53
+ opts: RenderOptions
54
+ ): string[] {
55
+ const ordered: string[] = [];
56
+ const seen = new Set<string>();
57
+ for (const [id] of membership) {
58
+ if (overlay.deletes.has(id) || seen.has(id)) continue;
59
+ seen.add(id);
60
+ ordered.push(id);
61
+ }
62
+ if (overlay.writes.size > 0) {
63
+ for (const [id] of localView) {
64
+ if (!overlay.writes.has(id) || overlay.deletes.has(id) || seen.has(id)) continue;
65
+ seen.add(id);
66
+ ordered.push(id);
67
+ }
68
+ }
69
+ if (!opts.hasExplicitOrder && !opts.isWindow) ordered.sort();
70
+ return ordered;
71
+ }
@@ -0,0 +1,112 @@
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
+ });
43
+ it('the LIVE on a per-user table is unfiltered; FETCH out only when bodies ride inline', () => {
44
+ expect(sql.listRefLive('_00_list_ref_user_x', { inlineBodies: true, sessionIds: ['s1'] })).toEqual({
45
+ sql: 'LIVE SELECT * FROM _00_list_ref_user_x FETCH out',
46
+ });
47
+ expect(sql.listRefLive('_00_list_ref', { inlineBodies: false, sessionIds: [] })).toEqual({
48
+ sql: 'LIVE SELECT * FROM _00_list_ref',
49
+ });
50
+ });
51
+ it("the anon LIVE hears only this client's sockets, current one by session::id()", () => {
52
+ const sids = ['earlier-1', 'earlier-2'];
53
+ const live = sql.listRefLive('_00_list_ref_anon', { inlineBodies: true, sessionIds: sids });
54
+ expect(live.sql).toBe(
55
+ 'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids FETCH out'
56
+ );
57
+ expect(live.vars).toEqual({ sids });
58
+ // A copy: the caller's list keeps growing after the LIVE is sent.
59
+ expect(live.vars!.sids).not.toBe(sids);
60
+ expect(sql.listRefLive('_00_list_ref_anon', { inlineBodies: false, sessionIds: [] })).toEqual({
61
+ sql: 'LIVE SELECT * FROM _00_list_ref_anon WHERE clientId = <string>session::id() OR clientId IN $sids',
62
+ vars: { sids: [] },
63
+ });
64
+ });
65
+ it('view rows and ids', () => {
66
+ expect(sql.viewRecordId('k')).toEqual(new RecordId('_00_view', 'k'));
67
+ expect(sql.viewRow([['t:1', 1]], [['c:1', 2]], true, 5)).toEqual({ ids: [['t:1', 1]], children: [['c:1', 2]], confirmed: true, updatedAt: 5 });
68
+ expect(sql.readViewRows()).toBe('SELECT * FROM _00_view');
69
+ expect(sql.readLegacyViewRows()).toBe('SELECT * FROM _00_window');
70
+ expect(sql.countViewRows()).toContain('_00_view');
71
+ expect(sql.bodySelect()).toBe('SELECT * FROM $ids');
72
+ });
73
+ it('heartbeat batch answers per index and detects reclaimed rows', () => {
74
+ const a = new RecordId('_00_query', 'a');
75
+ const b = new RecordId('_00_query', 'b');
76
+ expect(sql.heartbeatBatch([a, b])).toEqual({
77
+ sql: 'fn::query::heartbeat($id0);\nfn::query::heartbeat($id1)',
78
+ vars: { id0: a, id1: b },
79
+ });
80
+ expect(sql.heartbeatRowGone([])).toBe(true);
81
+ expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
82
+ expect(sql.heartbeatRowGone(undefined)).toBe(false);
83
+ });
84
+ it('the pagehide beacon names the page sessions and inlines only shapes it minted', () => {
85
+ const a = new RecordId('_00_query', 'a'.repeat(64));
86
+ const b = new RecordId('_00_query', 'b'.repeat(64));
87
+ const s1 = 'de81a067-f4bc-4351-a2a3-1ecf06b6305a';
88
+ const s2 = '8A14ED5A-32B5-4B4E-973D-D2B88860932F';
89
+ expect(sql.leaveViewsBeacon([a, b], [s1, s2])).toBe(
90
+ `FOR $id IN [${a}, ${b}] { LET $_left = fn::query::leave($id, ['${s1}', '${s2}']); };`
91
+ );
92
+ const digits = new RecordId('_00_query', '1'.repeat(64));
93
+ expect(sql.leaveViewsBeacon([digits], [s1])).toBe(`FOR $id IN [${digits}] { LET $_left = fn::query::leave($id, ['${s1}']); };`);
94
+ // Anything else is dropped rather than interpolated.
95
+ expect(sql.leaveViewsBeacon([a, new RecordId('_00_query', "x']; DELETE user; --"), 'user:1'], [s1, "x'; DELETE user; --"])).toBe(
96
+ `FOR $id IN [${a}] { LET $_left = fn::query::leave($id, ['${s1}']); };`
97
+ );
98
+ // No session ids: the server could not tell this page's entries from a reload's, so send nothing.
99
+ expect(sql.leaveViewsBeacon([a], [])).toBeNull();
100
+ expect(sql.leaveViewsBeacon([], [s1])).toBeNull();
101
+ });
102
+ it('upsertBodiesTx MERGEs each body inside one transaction', () => {
103
+ const { query, vars } = sql.upsertBodiesTx([
104
+ { id: 'thing:1', content: { a: 1 } },
105
+ { id: 'thing:2', content: { b: 2 } },
106
+ ]);
107
+ expect(query.sql).toBe(
108
+ 'BEGIN TRANSACTION;\nUPSERT ONLY $id0 MERGE $content0;UPSERT ONLY $id1 MERGE $content1;\nCOMMIT TRANSACTION;'
109
+ );
110
+ expect(vars).toEqual({ id0: 'thing:1', content0: { a: 1 }, id1: 'thing:2', content1: { b: 2 } });
111
+ });
112
+ });
@@ -0,0 +1,149 @@
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
+
98
+ /** Register + read back edges/meta/children in ONE request. */
99
+ export const registerSelect = (table: string): string =>
100
+ `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
101
+ export const registerVars = (payload: RegisterPayload): Vars => ({ config: payload, in: payload.id });
102
+
103
+ /** One heartbeat statement per view id; result index i answers id i. */
104
+ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: string; vars: Vars } {
105
+ const vars: Vars = {};
106
+ const stmts = ids.map((id, i) => {
107
+ vars[`id${i}`] = id;
108
+ return `fn::query::heartbeat($id${i})`;
109
+ });
110
+ return { sql: stmts.join(';\n'), vars };
111
+ }
112
+
113
+ /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
114
+ export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
115
+
116
+ // The SDK escapes an all-digit key as `⟨…⟩`; both forms parse back to the same id.
117
+ const QUERY_ID = /^_00_query:(?:[0-9a-f]{64}|⟨[0-9a-f]{64}⟩)$/;
118
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
119
+
120
+ /**
121
+ * The pagehide release: one bare statement for `beaconSql`, which has no params
122
+ * channel, so ids are inlined. Only shapes the client minted itself get in, a
123
+ * `_00_query:<sha256>` id and a UUID session id, so nothing else is ever
124
+ * interpolated. `null` when there is nothing the server could act on: with no
125
+ * session ids it could not tell this page's entries from anyone else's.
126
+ */
127
+ export function leaveViewsBeacon(ids: ReadonlyArray<unknown>, sessionIds: ReadonlyArray<string>): string | null {
128
+ const list = ids.map((id) => String(id)).filter((id) => QUERY_ID.test(id));
129
+ const sids = sessionIds.filter((sid) => SESSION_ID.test(sid));
130
+ if (list.length === 0 || sids.length === 0) return null;
131
+ const sidList = sids.map((sid) => `'${sid}'`).join(', ');
132
+ return `FOR $id IN [${list.join(', ')}] { LET $_left = fn::query::leave($id, [${sidList}]); };`;
133
+ }
134
+
135
+ export const bodySelect = (): string => 'SELECT * FROM $ids';
136
+
137
+ /** Local transaction that MERGEs many fetched bodies. */
138
+ export function upsertBodiesTx(records: ReadonlyArray<{ id: unknown; content: Record<string, unknown> }>): {
139
+ query: SealedQuery<unknown>;
140
+ vars: Vars;
141
+ } {
142
+ const vars: Vars = {};
143
+ const stmts = records.map((r, i) => {
144
+ vars[`id${i}`] = r.id;
145
+ vars[`content${i}`] = r.content;
146
+ return surql.upsertMerge(`id${i}`, `content${i}`);
147
+ });
148
+ return { query: surql.seal<void>(surql.tx(stmts)) as SealedQuery<unknown>, vars };
149
+ }