@spooky-sync/core 0.0.1-canary.240 → 0.0.1-canary.241

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 (289) hide show
  1. package/AGENTS.md +34 -15
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +1136 -1846
  4. package/dist/index.js +10168 -11267
  5. package/dist/types.d.ts +22 -79
  6. package/package.json +8 -4
  7. package/skills/sp00ky-core/SKILL.md +18 -0
  8. package/src/boot/auth-flip.saga.test.ts +82 -0
  9. package/src/boot/auth-flip.saga.ts +62 -0
  10. package/src/boot/boot.saga.test.ts +151 -0
  11. package/src/boot/boot.saga.ts +128 -0
  12. package/src/boot/bucket-switch.saga.test.ts +158 -0
  13. package/src/boot/bucket-switch.saga.ts +115 -0
  14. package/src/boot/preload.saga.test.ts +51 -0
  15. package/src/boot/preload.saga.ts +31 -0
  16. package/src/client/bucket-handle.test.ts +117 -0
  17. package/src/client/bucket-handle.ts +227 -0
  18. package/src/client/router.test.ts +56 -0
  19. package/src/client/router.ts +94 -0
  20. package/src/client/runtime.test.ts +192 -0
  21. package/src/client/runtime.ts +296 -0
  22. package/src/client/services.ts +349 -0
  23. package/src/client/sp00ky-client.test.ts +241 -0
  24. package/src/client/sp00ky-client.ts +482 -0
  25. package/src/index.ts +1 -0
  26. package/src/kernel/constants.test.ts +19 -0
  27. package/src/kernel/constants.ts +54 -0
  28. package/src/kernel/effects.test.ts +40 -0
  29. package/src/kernel/effects.ts +159 -0
  30. package/src/kernel/events.ts +55 -0
  31. package/src/kernel/interpreter.test.ts +116 -0
  32. package/src/kernel/interpreter.ts +143 -0
  33. package/src/kernel/saga.test.ts +89 -0
  34. package/src/kernel/saga.ts +73 -0
  35. package/src/modules/app-release/index.ts +13 -5
  36. package/src/modules/devtools/index.ts +10 -3
  37. package/src/modules/devtools/state-shape.test.ts +4 -1
  38. package/src/modules/feature-flag/index.ts +13 -5
  39. package/src/mutation/jobs.test.ts +25 -0
  40. package/src/mutation/jobs.ts +41 -0
  41. package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
  42. package/src/mutation/push.saga.test.ts +285 -0
  43. package/src/mutation/push.saga.ts +215 -0
  44. package/src/mutation/rows.test.ts +201 -0
  45. package/src/mutation/rows.ts +377 -0
  46. package/src/mutation/tray.saga.test.ts +63 -0
  47. package/src/mutation/tray.saga.ts +48 -0
  48. package/src/mutation/write.saga.test.ts +163 -0
  49. package/src/mutation/write.saga.ts +166 -0
  50. package/src/query/env.test.ts +23 -0
  51. package/src/query/env.ts +56 -0
  52. package/src/query/fetch.saga.test.ts +156 -0
  53. package/src/query/fetch.saga.ts +109 -0
  54. package/src/query/hash.test.ts +14 -0
  55. package/src/query/hash.ts +18 -0
  56. package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
  57. package/src/query/lifecycle.saga.test.ts +122 -0
  58. package/src/query/lifecycle.saga.ts +101 -0
  59. package/src/query/materialize.saga.test.ts +82 -0
  60. package/src/query/materialize.saga.ts +75 -0
  61. package/src/query/materialize.test.ts +50 -0
  62. package/src/query/materialize.ts +44 -0
  63. package/src/query/membership.saga.test.ts +246 -0
  64. package/src/query/membership.saga.ts +208 -0
  65. package/src/query/membership.test.ts +118 -0
  66. package/src/query/membership.ts +158 -0
  67. package/src/query/register.saga.test.ts +251 -0
  68. package/src/query/register.saga.ts +190 -0
  69. package/src/query/render-set.test.ts +36 -0
  70. package/src/query/render-set.ts +60 -0
  71. package/src/query/sql.test.ts +62 -0
  72. package/src/query/sql.ts +79 -0
  73. package/src/{modules/data → query}/window-query.test.ts +18 -0
  74. package/src/{modules/data → query}/window-query.ts +2 -1
  75. package/src/services/database/connection-supervisor.test.ts +29 -0
  76. package/src/services/database/connection-supervisor.ts +7 -0
  77. package/src/services/database/database.responses.test.ts +43 -0
  78. package/src/services/database/database.ts +25 -10
  79. package/src/services/database/local-migrator.ts +2 -2
  80. package/src/services/database/plan-render.test.ts +1 -1
  81. package/src/services/database/sqlite-cache-engine.test.ts +4 -3
  82. package/src/services/database/sqlite-cache-engine.ts +2 -11
  83. package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
  84. package/src/services/tabs/protocol.ts +7 -3
  85. package/src/sp00ky.ts +5 -1854
  86. package/src/state/client-state.ts +184 -0
  87. package/src/state/lifecycle.test.ts +118 -0
  88. package/src/state/lifecycle.ts +86 -0
  89. package/src/state/reducers.test.ts +280 -0
  90. package/src/state/reducers.ts +476 -0
  91. package/src/state/selectors.test.ts +149 -0
  92. package/src/state/selectors.ts +184 -0
  93. package/src/sync/connection.saga.test.ts +86 -0
  94. package/src/sync/connection.saga.ts +85 -0
  95. package/src/sync/live.saga.test.ts +69 -0
  96. package/src/sync/live.saga.ts +49 -0
  97. package/src/sync/policy.health.test.ts +56 -0
  98. package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
  99. package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
  100. package/src/sync/poll.saga.test.ts +51 -0
  101. package/src/sync/poll.saga.ts +36 -0
  102. package/src/sync/tabs.saga.test.ts +74 -0
  103. package/src/sync/tabs.saga.ts +92 -0
  104. package/src/testing/adapters.ts +133 -0
  105. package/src/testing/build.ts +61 -0
  106. package/src/testing/fake-services.ts +34 -0
  107. package/src/testing/run-pure.test.ts +92 -0
  108. package/src/testing/run-pure.ts +128 -0
  109. package/src/testing/services.ts +17 -0
  110. package/src/types.ts +8 -7
  111. package/src/utils/utils.test.ts +2 -1
  112. package/vitest.config.ts +48 -0
  113. package/coverage/lcov-report/base.css +0 -224
  114. package/coverage/lcov-report/block-navigation.js +0 -87
  115. package/coverage/lcov-report/favicon.png +0 -0
  116. package/coverage/lcov-report/index.html +0 -476
  117. package/coverage/lcov-report/prettify.css +0 -1
  118. package/coverage/lcov-report/prettify.js +0 -2
  119. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  120. package/coverage/lcov-report/sorter.js +0 -210
  121. package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
  122. package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
  123. package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
  124. package/coverage/lcov-report/src/boot/index.html +0 -161
  125. package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
  126. package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
  127. package/coverage/lcov-report/src/client/index.html +0 -161
  128. package/coverage/lcov-report/src/client/router.ts.html +0 -367
  129. package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
  130. package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
  131. package/coverage/lcov-report/src/events/index.html +0 -116
  132. package/coverage/lcov-report/src/events/index.ts.html +0 -877
  133. package/coverage/lcov-report/src/index.html +0 -146
  134. package/coverage/lcov-report/src/index.ts.html +0 -202
  135. package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
  136. package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
  137. package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
  138. package/coverage/lcov-report/src/kernel/index.html +0 -176
  139. package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
  140. package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
  141. package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
  142. package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
  143. package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
  144. package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
  145. package/coverage/lcov-report/src/modules/auth/index.html +0 -116
  146. package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
  147. package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
  148. package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
  149. package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
  150. package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
  151. package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
  152. package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
  153. package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
  154. package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
  155. package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
  156. package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
  157. package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
  158. package/coverage/lcov-report/src/modules/index.html +0 -116
  159. package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
  160. package/coverage/lcov-report/src/mutation/index.html +0 -191
  161. package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
  162. package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
  163. package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
  164. package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
  165. package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
  166. package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
  167. package/coverage/lcov-report/src/otel/index.html +0 -116
  168. package/coverage/lcov-report/src/otel/index.ts.html +0 -466
  169. package/coverage/lcov-report/src/query/env.ts.html +0 -253
  170. package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
  171. package/coverage/lcov-report/src/query/hash.ts.html +0 -139
  172. package/coverage/lcov-report/src/query/index.html +0 -281
  173. package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
  174. package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
  175. package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
  176. package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
  177. package/coverage/lcov-report/src/query/membership.ts.html +0 -559
  178. package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
  179. package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
  180. package/coverage/lcov-report/src/query/sql.ts.html +0 -322
  181. package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
  182. package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
  183. package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
  184. package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
  185. package/coverage/lcov-report/src/services/blobs/index.html +0 -161
  186. package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
  187. package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
  188. package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
  189. package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
  190. package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
  191. package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
  192. package/coverage/lcov-report/src/services/database/events/index.html +0 -116
  193. package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
  194. package/coverage/lcov-report/src/services/database/index.html +0 -386
  195. package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
  196. package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
  197. package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
  198. package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
  199. package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
  200. package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
  201. package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
  202. package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
  203. package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
  204. package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
  205. package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
  206. package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
  207. package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
  208. package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
  209. package/coverage/lcov-report/src/services/logger/index.html +0 -116
  210. package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
  211. package/coverage/lcov-report/src/services/persistence/index.html +0 -146
  212. package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
  213. package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
  214. package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
  215. package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
  216. package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
  217. package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
  218. package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
  219. package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
  220. package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
  221. package/coverage/lcov-report/src/services/tabs/index.html +0 -191
  222. package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
  223. package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
  224. package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
  225. package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
  226. package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
  227. package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
  228. package/coverage/lcov-report/src/state/index.html +0 -161
  229. package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
  230. package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
  231. package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
  232. package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
  233. package/coverage/lcov-report/src/sync/index.html +0 -176
  234. package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
  235. package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
  236. package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
  237. package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
  238. package/coverage/lcov-report/src/types.ts.html +0 -2419
  239. package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
  240. package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
  241. package/coverage/lcov-report/src/utils/index.html +0 -191
  242. package/coverage/lcov-report/src/utils/index.ts.html +0 -820
  243. package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
  244. package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
  245. package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
  246. package/coverage/lcov.info +0 -19079
  247. package/src/modules/cache/cache.relay.test.ts +0 -95
  248. package/src/modules/cache/index.ts +0 -361
  249. package/src/modules/cache/types.ts +0 -19
  250. package/src/modules/data/data.activity.test.ts +0 -109
  251. package/src/modules/data/data.authority.test.ts +0 -122
  252. package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
  253. package/src/modules/data/data.hydration.test.ts +0 -142
  254. package/src/modules/data/data.membership.test.ts +0 -623
  255. package/src/modules/data/data.notify-table.test.ts +0 -41
  256. package/src/modules/data/data.pending-ids.test.ts +0 -199
  257. package/src/modules/data/data.rebind.test.ts +0 -170
  258. package/src/modules/data/data.rematerialize.test.ts +0 -114
  259. package/src/modules/data/data.run.test.ts +0 -113
  260. package/src/modules/data/data.settled-writes.test.ts +0 -235
  261. package/src/modules/data/data.status.test.ts +0 -249
  262. package/src/modules/data/data.test.ts +0 -58
  263. package/src/modules/data/index.ts +0 -2745
  264. package/src/modules/sync/engine.ts +0 -250
  265. package/src/modules/sync/events/index.ts +0 -84
  266. package/src/modules/sync/index.ts +0 -3
  267. package/src/modules/sync/queue/index.ts +0 -2
  268. package/src/modules/sync/queue/queue-down.test.ts +0 -228
  269. package/src/modules/sync/queue/queue-down.ts +0 -184
  270. package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
  271. package/src/modules/sync/queue/queue-up.ts +0 -407
  272. package/src/modules/sync/scheduler.pause.test.ts +0 -109
  273. package/src/modules/sync/scheduler.retry.test.ts +0 -237
  274. package/src/modules/sync/scheduler.ts +0 -286
  275. package/src/modules/sync/sync.cleanup.test.ts +0 -116
  276. package/src/modules/sync/sync.health.test.ts +0 -167
  277. package/src/modules/sync/sync.heartbeat.test.ts +0 -80
  278. package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
  279. package/src/modules/sync/sync.live-removal.test.ts +0 -289
  280. package/src/modules/sync/sync.reconnect.test.ts +0 -145
  281. package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
  282. package/src/modules/sync/sync.settled-poll.test.ts +0 -58
  283. package/src/modules/sync/sync.subquery.test.ts +0 -82
  284. package/src/modules/sync/sync.tabs.test.ts +0 -249
  285. package/src/modules/sync/sync.ts +0 -2626
  286. package/src/sp00ky.auth-order.test.ts +0 -112
  287. package/src/sp00ky.init-query.test.ts +0 -183
  288. package/src/sp00ky.local-first.test.ts +0 -60
  289. /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
@@ -0,0 +1,158 @@
1
+ import type { RecordId } from 'surrealdb';
2
+ import type { MembershipOutcome, RecordVersionArray, ServerViewMeta } from '../types';
3
+ import type { QueryPhase } from '../state/lifecycle';
4
+ import { encodeRecordId } from '../utils/index';
5
+
6
+ /** The durable `_00_view` row: the last server-confirmed membership. */
7
+ export interface DurableView {
8
+ ids: RecordVersionArray;
9
+ confirmed: boolean;
10
+ }
11
+
12
+ export function parseViewRow(row: unknown): DurableView | null {
13
+ if (!row || typeof row !== 'object') return null;
14
+ const ids = (row as { ids?: unknown }).ids;
15
+ if (!Array.isArray(ids)) return null;
16
+ return { ids: ids as RecordVersionArray, confirmed: (row as { confirmed?: unknown }).confirmed === true };
17
+ }
18
+
19
+ /**
20
+ * "Resolved before": the server answered this query on this device at some
21
+ * point. An unconfirmed empty row cannot be told apart from one written before
22
+ * the marker existed, so it does not count.
23
+ */
24
+ export const isResolvedBefore = (view: DurableView | null): boolean =>
25
+ view !== null && (view.ids.length > 0 || view.confirmed);
26
+
27
+ /** The `{ rowCount, state }` object the row-count selects return per row. */
28
+ export interface QueryMetaRow {
29
+ id?: RecordId<string>;
30
+ rowCount?: number | null;
31
+ state?: string | null;
32
+ }
33
+
34
+ export function metaFromRow(row: QueryMetaRow | null | undefined): ServerViewMeta {
35
+ if (!row || typeof row !== 'object') return { present: false, rowCount: null, state: null };
36
+ const state = row.state === 'materializing' || row.state === 'ready' ? row.state : null;
37
+ return { present: true, rowCount: typeof row.rowCount === 'number' ? row.rowCount : null, state };
38
+ }
39
+
40
+ /** Collapse duplicate `(id, version)` pairs to one per id, keeping the highest version. */
41
+ export function dedupeRecordVersions(pairs: RecordVersionArray): RecordVersionArray {
42
+ if (pairs.length < 2) return pairs;
43
+ const best = new Map<string, number>();
44
+ for (const [id, version] of pairs) {
45
+ const prev = best.get(id);
46
+ if (prev === undefined || version > prev) best.set(id, version);
47
+ }
48
+ if (best.size === pairs.length) return pairs;
49
+ return Array.from(best.entries());
50
+ }
51
+
52
+ export interface MembershipDecisionInput {
53
+ phase: QueryPhase;
54
+ /** Number of ids currently held as membership. */
55
+ held: number;
56
+ remoteArray: RecordVersionArray;
57
+ meta?: ServerViewMeta;
58
+ verifiedRemoval?: boolean;
59
+ }
60
+
61
+ /**
62
+ * Whether a server id-set may be applied. A NON-EMPTY set is always the
63
+ * server's answer. An EMPTY set is believed only when the server stands
64
+ * behind it: the `_00_query` row is present, `ready` (or pre-`state`), and
65
+ * reports zero rows. A missing row while we hold membership is a lost view.
66
+ */
67
+ export function decideMembershipOutcome(input: MembershipDecisionInput): MembershipOutcome {
68
+ const { phase, held, remoteArray, meta, verifiedRemoval } = input;
69
+ if (remoteArray.length > 0 || verifiedRemoval) return 'applied';
70
+ if (!meta || !meta.present) {
71
+ return held > 0 || phase !== 'cold' ? 'view-lost' : 'ignored';
72
+ }
73
+ const knownEmpty = meta.rowCount === 0 && meta.state !== 'materializing';
74
+ return knownEmpty ? 'applied' : 'ignored';
75
+ }
76
+
77
+ // ---- list_ref snapshots ------------------------------------------------------
78
+
79
+ export interface ListRefEdgeRow {
80
+ in: RecordId<string>;
81
+ out: RecordId<string>;
82
+ version: number;
83
+ parent?: unknown;
84
+ }
85
+
86
+ export interface ListRefSnapshot {
87
+ primary: RecordVersionArray;
88
+ subquery: RecordVersionArray;
89
+ meta: ServerViewMeta;
90
+ }
91
+
92
+ const toPairs = (rows: Array<{ out: RecordId<string>; version: number }> | null | undefined): RecordVersionArray =>
93
+ dedupeRecordVersions(Array.isArray(rows) ? rows.map((r) => [encodeRecordId(r.out), r.version]) : []);
94
+
95
+ /** Fold the single-query statement batch (edges, meta, children) into a snapshot. */
96
+ export function snapshotFromSingle(
97
+ items: Array<{ out: RecordId<string>; version: number }> | null,
98
+ metaRow: QueryMetaRow | null,
99
+ children: Array<{ out: RecordId<string>; version: number }> | null
100
+ ): ListRefSnapshot | null {
101
+ if (!Array.isArray(items)) return null;
102
+ return { primary: toPairs(items), subquery: toPairs(children), meta: metaFromRow(metaRow) };
103
+ }
104
+
105
+ /**
106
+ * Fold the many-query statement batch into one snapshot per hash. Every hash
107
+ * in `hashById` gets a snapshot; a query whose row did not come back reads
108
+ * `present: false`.
109
+ */
110
+ export function snapshotsFromBatch(
111
+ edges: ListRefEdgeRow[] | null,
112
+ counts: Array<QueryMetaRow | null> | null,
113
+ hashById: ReadonlyMap<string, string>
114
+ ): Map<string, ListRefSnapshot> {
115
+ const out = new Map<string, ListRefSnapshot>();
116
+ if (!Array.isArray(edges)) return out;
117
+ for (const hash of hashById.values()) {
118
+ out.set(hash, { primary: [], subquery: [], meta: { present: false, rowCount: null, state: null } });
119
+ }
120
+ for (const row of edges) {
121
+ const hash = hashById.get(encodeRecordId(row.in));
122
+ if (!hash) continue;
123
+ const snapshot = out.get(hash)!;
124
+ const pair: [string, number] = [encodeRecordId(row.out), row.version];
125
+ if (row.parent == null) snapshot.primary.push(pair);
126
+ else snapshot.subquery.push(pair);
127
+ }
128
+ for (const snapshot of out.values()) {
129
+ snapshot.primary = dedupeRecordVersions(snapshot.primary);
130
+ snapshot.subquery = dedupeRecordVersions(snapshot.subquery);
131
+ }
132
+ for (const count of Array.isArray(counts) ? counts : []) {
133
+ if (!count || !count.id) continue;
134
+ const hash = hashById.get(encodeRecordId(count.id));
135
+ if (!hash) continue;
136
+ out.get(hash)!.meta = metaFromRow(count);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * A batch that must not be believed at face value: a query we hold rows for
143
+ * whose `_00_query` row did not come back, or no edge at all while the server
144
+ * still reports rows for a held query. Such hashes are re-read one at a time.
145
+ */
146
+ export function suspectHashes(
147
+ snapshots: ReadonlyMap<string, ListRefSnapshot>,
148
+ heldCounts: ReadonlyMap<string, number>,
149
+ edgeCount: number
150
+ ): string[] {
151
+ const out: string[] = [];
152
+ for (const [hash, snapshot] of snapshots) {
153
+ const held = heldCounts.get(hash) ?? 0;
154
+ if (held === 0) continue;
155
+ if (!snapshot.meta.present || (edgeCount === 0 && (snapshot.meta.rowCount ?? 0) > 0)) out.push(hash);
156
+ }
157
+ return out;
158
+ }
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure, sha256Hex } from '../testing/run-pure';
4
+ import { buildEntry, buildState } from '../testing/build';
5
+ import { defaultEnv } from './env';
6
+ import { queryHashInput } from './hash';
7
+ import { ensureRegistered, registerLocal, registerRemote } from './register.saga';
8
+ import * as R from '../state/reducers';
9
+ import { emptyState } from '../state/client-state';
10
+ import type { StatementResult } from '../kernel/effects';
11
+
12
+ const env = defaultEnv({ tables: [{ name: 'thing', columns: {} }] } as any);
13
+ const input = { tableName: 'thing', surql: 'SELECT * FROM thing', params: {}, ttl: '10m' as const };
14
+ const sspOk = () => ({ localArray: [['thing:9', 1]], timings: { parseMs: 1, planMs: 1, snapshotMs: 1, wallMs: 1 } });
15
+ const okStmt = (result: unknown): StatementResult => ({ status: 'OK', result });
16
+
17
+ describe('registerLocal', () => {
18
+ it('cold: no view row -> cold entry, SSP view, EnsureRegistered', async () => {
19
+ const state = { ...emptyState({ tabId: 't' }), sessionId: 'sess' };
20
+ const out = await runPure(registerLocal(env, input), {
21
+ state,
22
+ handlers: { 'local.getById': () => null, 'ssp.register': sspOk },
23
+ });
24
+ const hash = await sha256Hex(queryHashInput(input, 'sess'));
25
+ expect(out.result).toBe(hash);
26
+ const entry = out.state.queries.get(hash)!;
27
+ expect(entry.lifecycle.phase).toBe('cold');
28
+ expect(entry.remoteArray).toEqual([]);
29
+ expect(entry.localArray).toEqual([['thing:9', 1]]);
30
+ expect(entry.def.ttlMs).toBe(600_000);
31
+ expect(entry.telemetry.registrationTimings.wallMs).toBe(1);
32
+ expect(out.state.dirty.has(hash)).toBe(true);
33
+ expect(out.state.registering.size).toBe(0);
34
+ expect(out.dispatched).toEqual([{ type: 'EnsureRegistered' }]);
35
+ const reg = out.log.find((e) => e.kind === 'ssp.register');
36
+ expect(reg).toMatchObject({ plan: { queryHash: hash, tableName: 'thing', ttl: '10m' } });
37
+ });
38
+
39
+ it('cached: a durable view row seeds membership; unconfirmed empty stays cold; a failing read is cold', async () => {
40
+ const cached = await runPure(registerLocal(env, input), {
41
+ handlers: { 'local.getById': () => ({ ids: [['thing:1', 2]], confirmed: false }), 'ssp.register': sspOk },
42
+ });
43
+ const e1 = cached.state.queries.get(cached.result)!;
44
+ expect(e1.lifecycle.phase).toBe('cached');
45
+ expect(e1.remoteArray).toEqual([['thing:1', 2]]);
46
+ const confirmedEmpty = await runPure(registerLocal(env, input), {
47
+ handlers: { 'local.getById': () => ({ ids: [], confirmed: true }), 'ssp.register': sspOk },
48
+ });
49
+ expect(confirmedEmpty.state.queries.get(confirmedEmpty.result)!.lifecycle.phase).toBe('cached');
50
+ const unconfirmed = await runPure(registerLocal(env, input), {
51
+ handlers: { 'local.getById': () => ({ ids: [], confirmed: false }), 'ssp.register': sspOk },
52
+ });
53
+ expect(unconfirmed.state.queries.get(unconfirmed.result)!.lifecycle.phase).toBe('cold');
54
+ const failing = await runPure(registerLocal(env, input), {
55
+ handlers: {
56
+ 'local.getById': () => {
57
+ throw new Error('no table');
58
+ },
59
+ 'ssp.register': sspOk,
60
+ },
61
+ });
62
+ expect(failing.state.queries.get(failing.result)!.lifecycle.phase).toBe('cold');
63
+ });
64
+
65
+ it('dedupes: active returns immediately, pending waits for the other registration', async () => {
66
+ const hash = await sha256Hex(queryHashInput(input, null));
67
+ const active = buildState([buildEntry({ def: { hash } })]);
68
+ const a = await runPure(registerLocal(env, input), { state: active });
69
+ expect(a.result).toBe(hash);
70
+ expect(a.log.filter((e) => e.kind === 'ssp.register')).toHaveLength(0);
71
+ const pending = R.beginRegistering(hash)(emptyState({ tabId: 't' }));
72
+ const p = await runPure(registerLocal(env, input), {
73
+ state: pending,
74
+ handlers: { 'state.wait': (_e, ctx) => void (ctx.state = R.endRegistering(hash)(ctx.state)) },
75
+ });
76
+ expect(p.result).toBe(hash);
77
+ expect(p.log.some((e) => e.kind === 'state.wait')).toBe(true);
78
+ });
79
+
80
+ it('a failing SSP registration clears the in-flight marker and rethrows', async () => {
81
+ await expect(
82
+ runPure(registerLocal(env, input), {
83
+ handlers: {
84
+ 'local.getById': () => null,
85
+ 'ssp.register': () => {
86
+ throw new Error('wasm down');
87
+ },
88
+ },
89
+ })
90
+ ).rejects.toThrow('wasm down');
91
+ });
92
+ });
93
+
94
+ describe('ensureRegistered', () => {
95
+ it('dispatches RegisterRemote for every unregistered query only', async () => {
96
+ const s = buildState([buildEntry({ def: { hash: 'a' } }), buildEntry({ def: { hash: 'b' }, lifecycle: { remote: 'registered' } })]);
97
+ const out = await runPure(ensureRegistered(env), { state: s });
98
+ expect(out.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
99
+ const none = await runPure(ensureRegistered(env), { state: buildState() });
100
+ expect(none.dispatched).toEqual([]);
101
+ });
102
+ it('with requireAuth: probes $auth.id, retries on a timer, gives up after the budget, skips when anonymous', async () => {
103
+ const s = { ...buildState([buildEntry({ def: { hash: 'a' } })]), userId: 'user:1' };
104
+ const authed = await runPure(ensureRegistered(env, { requireAuth: true }), {
105
+ state: s,
106
+ handlers: { 'remote.query': () => [okStmt('user:1')] },
107
+ });
108
+ expect(authed.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
109
+ const notYet = await runPure(ensureRegistered(env, { requireAuth: true, attempt: 2 }), {
110
+ state: s,
111
+ handlers: { 'remote.query': () => [okStmt(null)] },
112
+ });
113
+ expect(notYet.dispatched).toEqual([]);
114
+ expect(notYet.timers.get('ensure-registered')).toEqual({ ms: 500, event: { type: 'EnsureRegistered', requireAuth: true, attempt: 3 } });
115
+ const thrown = await runPure(ensureRegistered(env, { requireAuth: true }), {
116
+ state: s,
117
+ handlers: {
118
+ 'remote.query': () => {
119
+ throw new Error('offline');
120
+ },
121
+ },
122
+ });
123
+ expect(thrown.timers.has('ensure-registered')).toBe(true);
124
+ const exhausted = await runPure(ensureRegistered(env, { requireAuth: true, attempt: 9 }), {
125
+ state: s,
126
+ handlers: { 'remote.query': () => [okStmt(null)] },
127
+ });
128
+ expect(exhausted.timers.size).toBe(0);
129
+ expect(exhausted.emitted).toEqual([expect.objectContaining({ type: 'log', level: 'warn' })]);
130
+ const anon = await runPure(ensureRegistered(env, { requireAuth: true }), { state: { ...s, userId: null } });
131
+ expect(anon.dispatched).toEqual([{ type: 'RegisterRemote', hash: 'a' }]);
132
+ });
133
+ });
134
+
135
+ describe('registerRemote', () => {
136
+ const entry = buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'cold' } });
137
+ const happy = () => [
138
+ okStmt(null),
139
+ okStmt([{ out: new RecordId('thing', '1'), version: 1 }]),
140
+ okStmt({ rowCount: 1, state: 'ready' }),
141
+ okStmt([{ out: new RecordId('child', 'c'), version: 1 }]),
142
+ ];
143
+ it('registers, applies membership + children, flips registered, fetch-begin/end balanced', async () => {
144
+ const out = await runPure(registerRemote(env, 'a'), {
145
+ state: buildState([entry]),
146
+ handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
147
+ });
148
+ const e = out.state.queries.get('a')!;
149
+ expect(e.lifecycle).toMatchObject({ phase: 'live', remote: 'registered', fetchDepth: 0 });
150
+ expect(e.remoteArray).toEqual([['thing:1', 1]]);
151
+ expect(e.subqueryRemoteArray).toEqual([['child:c', 1]]);
152
+ expect(e.serverState).toBe('ready');
153
+ const req = out.log.find((x) => x.kind === 'remote.query') as any;
154
+ expect(req.sql.startsWith('fn::query::register($config);')).toBe(true);
155
+ expect(req.vars.config).toMatchObject({ surql: entry.def.surql, ttl: '10m' });
156
+ expect(out.dispatched).toEqual([{ type: 'FetchRows' }, { type: 'FetchRows' }, { type: 'SyncOutcome', ok: true }]);
157
+ expect(out.emitted).toContainEqual({ type: 'query:authority', hash: 'a', known: true });
158
+ });
159
+ it('guards: missing entry, already registered / failed, registering without retry', async () => {
160
+ const none = await runPure(registerRemote(env, 'zzz'), { state: buildState([]) });
161
+ expect(none.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
162
+ for (const remote of ['registered', 'failed', 'registering'] as const) {
163
+ const out = await runPure(registerRemote(env, 'a'), { state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote } })]) });
164
+ expect(out.log.filter((x) => x.kind === 'remote.query')).toHaveLength(0);
165
+ }
166
+ const retry = await runPure(registerRemote(env, 'a', true), {
167
+ state: buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registering' } })]),
168
+ handlers: { 'remote.query': happy, 'local.upsert': () => undefined },
169
+ });
170
+ expect(retry.state.queries.get('a')!.lifecycle.remote).toBe('registered');
171
+ });
172
+ it('materializing answer schedules the re-read ladder', async () => {
173
+ const out = await runPure(registerRemote(env, 'a'), {
174
+ state: buildState([entry]),
175
+ handlers: { 'remote.query': () => [okStmt(null), okStmt([]), okStmt({ rowCount: 3, state: 'materializing' }), okStmt([])] },
176
+ });
177
+ expect(out.state.membershipDirty.has('a')).toBe(true);
178
+ expect(out.state.membershipReread.get('a')).toBe(1);
179
+ expect(out.timers.get('membership')).toEqual({ ms: 150, event: { type: 'ReadDirtyMembership' } });
180
+ expect(out.state.queries.get('a')!.lifecycle).toMatchObject({ phase: 'cold', remote: 'registered' });
181
+ });
182
+ it('errors: transport failure backs off, statement errors count as failures, budget exhausts to failed', async () => {
183
+ const boom = () => {
184
+ throw new Error('socket closed');
185
+ };
186
+ const first = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': boom } });
187
+ expect(first.state.queries.get('a')!.lifecycle).toMatchObject({ remote: 'registering', fetchDepth: 0 });
188
+ expect(first.state.queries.get('a')!.registerAttempts).toBe(1);
189
+ expect(first.timers.get('register:a')).toEqual({ ms: 1000, event: { type: 'RegisterRemote', hash: 'a', retry: true } });
190
+ expect(first.dispatched).toContainEqual({ type: 'SyncOutcome', ok: false, error: new Error('socket closed') });
191
+ const errStmt = await runPure(registerRemote(env, 'a'), {
192
+ state: buildState([entry]),
193
+ handlers: { 'remote.query': () => [{ status: 'ERR', error: 'denied' }] },
194
+ });
195
+ expect(errStmt.state.queries.get('a')!.registerAttempts).toBe(1);
196
+ const edgesErr = await runPure(registerRemote(env, 'a'), {
197
+ state: buildState([entry]),
198
+ handlers: { 'remote.query': () => [okStmt(null), { status: 'ERR', error: 'no table' }, okStmt(null), okStmt(null)] },
199
+ });
200
+ expect(edgesErr.state.queries.get('a')!.registerAttempts).toBe(1);
201
+ const noArray = await runPure(registerRemote(env, 'a'), {
202
+ state: buildState([entry]),
203
+ handlers: { 'remote.query': () => [okStmt(null), okStmt('nope'), okStmt(null), okStmt(null)] },
204
+ });
205
+ expect(noArray.state.queries.get('a')!.registerAttempts).toBe(1);
206
+ const empty = await runPure(registerRemote(env, 'a'), { state: buildState([entry]), handlers: { 'remote.query': () => [] } });
207
+ expect(empty.state.queries.get('a')!.registerAttempts).toBe(1);
208
+ const last = await runPure(registerRemote(env, 'a'), {
209
+ state: buildState([buildEntry({ def: { hash: 'a' }, registerAttempts: 2 })]),
210
+ handlers: { 'remote.query': boom },
211
+ });
212
+ expect(last.state.queries.get('a')!.lifecycle.remote).toBe('failed');
213
+ expect(last.timers.size).toBe(0);
214
+ });
215
+ it('tolerates ERR meta/children statements: edges still apply, missing meta reads as no row', async () => {
216
+ const out = await runPure(registerRemote(env, 'a'), {
217
+ state: buildState([entry]),
218
+ handlers: {
219
+ 'remote.query': () => [okStmt(null), okStmt([{ out: new RecordId('thing', '1'), version: 1 }]), { status: 'ERR', error: 'meta' }, { status: 'ERR', error: 'kids' }],
220
+ 'local.upsert': () => undefined,
221
+ },
222
+ });
223
+ const e = out.state.queries.get('a')!;
224
+ expect(e.lifecycle).toMatchObject({ phase: 'live', remote: 'registered' });
225
+ expect(e.serverState).toBeNull();
226
+ expect(e.subqueryRemoteArray).toEqual([]);
227
+ });
228
+ it('stops when the entry disappears mid-flight', async () => {
229
+ const out = await runPure(registerRemote(env, 'a'), {
230
+ state: buildState([entry]),
231
+ handlers: {
232
+ 'remote.query': (_e, ctx) => {
233
+ ctx.state = R.removeQuery('a')(ctx.state);
234
+ return happy();
235
+ },
236
+ },
237
+ });
238
+ expect(out.state.queries.has('a')).toBe(false);
239
+ expect(out.dispatched).toEqual([]);
240
+ const gone = await runPure(registerRemote(env, 'a'), {
241
+ state: buildState([entry]),
242
+ handlers: {
243
+ 'remote.query': (_e, ctx) => {
244
+ ctx.state = R.removeQuery('a')(ctx.state);
245
+ throw new Error('x');
246
+ },
247
+ },
248
+ });
249
+ expect(gone.dispatched).toEqual([]);
250
+ });
251
+ });
@@ -0,0 +1,190 @@
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 { isResolvedBefore, parseViewRow, snapshotFromSingle } from './membership';
18
+ import { applyMembership, applySubqueryChildren } 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
+ telemetry: { ...emptyTelemetry(), registrationTimings: reg.timings },
90
+ };
91
+ yield fx.state.update(R.putQuery(entry));
92
+ yield fx.dispatch({ type: 'EnsureRegistered' });
93
+ return hash;
94
+ } finally {
95
+ yield fx.state.update(R.endRegistering(hash));
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Make the remote match the desired set: start a registration for every
101
+ * query that has none. Idempotent. With `requireAuth` (after a reconnect or
102
+ * while degraded) it first waits for `$auth.id` to be visible on the socket:
103
+ * a register issued before that stamps the view with an empty identity.
104
+ */
105
+ export function* ensureRegistered(env: SagaEnv, opts: { requireAuth?: boolean; attempt?: number } = {}): Saga<void> {
106
+ const [hashes, userId] = (yield fx.state.read((s) => [desiredRegistrations(s), s.userId])) as [QueryHash[], string | null];
107
+ if (hashes.length === 0) return;
108
+ if (opts.requireAuth && userId !== null) {
109
+ let authed = false;
110
+ try {
111
+ const res = (yield fx.remote.query('RETURN $auth.id', undefined, env.remoteTimeoutMs)) as StatementResult[];
112
+ authed = res[0]?.status === 'OK' && res[0].result != null;
113
+ } catch {
114
+ authed = false;
115
+ }
116
+ if (!authed) {
117
+ const attempt = (opts.attempt ?? 0) + 1;
118
+ if (attempt >= AUTH_READY_MAX_ATTEMPTS) {
119
+ yield fx.emit({ type: 'log', level: 'warn', message: 'auth identity never became visible; not registering', data: { attempt } });
120
+ return;
121
+ }
122
+ yield fx.timer.set('ensure-registered', AUTH_READY_RETRY_MS, { type: 'EnsureRegistered', requireAuth: true, attempt });
123
+ return;
124
+ }
125
+ }
126
+ for (const hash of hashes) yield fx.dispatch({ type: 'RegisterRemote', hash });
127
+ }
128
+
129
+ const stmt = (results: StatementResult[], i: number): StatementResult | undefined => results[i];
130
+
131
+ /**
132
+ * One remote registration: `fn::query::register` plus the edge/meta/children
133
+ * read in ONE request, then membership application. Concurrent with every
134
+ * other registration; retried on its own backoff; stops when the entry is
135
+ * gone.
136
+ */
137
+ export function* registerRemote(env: SagaEnv, hash: QueryHash, retry = false): Saga<void> {
138
+ const state = (yield fx.state.read((s) => s)) as ClientState;
139
+ const entry = state.queries.get(hash);
140
+ if (!entry) return;
141
+ const { remote } = entry.lifecycle;
142
+ if (remote === 'registered' || remote === 'failed' || (remote === 'registering' && !retry)) return;
143
+ yield fx.state.update(
144
+ R.compose(R.applyLifecycle(hash, { type: 'remote-registering' }), R.applyLifecycle(hash, { type: 'fetch-begin' }))
145
+ );
146
+ try {
147
+ const table = listRefTable(env, state);
148
+ const results = (yield fx.remote.query(
149
+ sql.registerSelect(table),
150
+ sql.registerVars({ id: entry.def.id, surql: entry.def.surql, params: { ...entry.def.params }, ttl: entry.def.ttl }),
151
+ env.remoteTimeoutMs
152
+ )) as StatementResult[];
153
+ const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
154
+ if (!stillThere) return;
155
+ const register = stmt(results, 0);
156
+ if (!register || register.status === 'ERR') throw new Error(register?.status === 'ERR' ? register.error : 'register returned nothing');
157
+ const edges = stmt(results, 1);
158
+ const meta = stmt(results, 2);
159
+ const children = stmt(results, 3);
160
+ const snap = snapshotFromSingle(
161
+ edges?.status === 'OK' ? (edges.result as never) : null,
162
+ meta?.status === 'OK' ? (meta.result as never) : null,
163
+ children?.status === 'OK' ? (children.result as never) : null
164
+ );
165
+ if (!snap) throw new Error(edges?.status === 'ERR' ? edges.error : 'edge read returned no array');
166
+ const outcome = yield* applyMembership(hash, snap.primary, snap.meta);
167
+ if (outcome === 'ignored' && snap.meta.present && snap.meta.state === 'materializing') {
168
+ yield fx.state.update(R.compose(R.markMembershipDirty([hash]), R.setMembershipReread(hash, 1)));
169
+ yield fx.timer.set('membership', 150, { type: 'ReadDirtyMembership' });
170
+ }
171
+ yield* applySubqueryChildren(hash, snap.subquery);
172
+ yield fx.state.update(R.compose(R.applyLifecycle(hash, { type: 'remote-registered' }), R.resetRegisterAttempts(hash)));
173
+ yield fx.dispatch({ type: 'SyncOutcome', ok: true });
174
+ } catch (error) {
175
+ const current = (yield fx.state.read((s) => s.queries.get(hash))) as QueryEntry | undefined;
176
+ if (!current) return;
177
+ const attempts = current.registerAttempts + 1;
178
+ yield fx.state.update(R.bumpRegisterAttempts(hash));
179
+ yield fx.emit({ type: 'log', level: 'warn', message: 'remote registration failed', data: { hash, attempts, error } });
180
+ yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
181
+ if (attempts >= REGISTER_MAX_RETRIES) {
182
+ yield fx.state.update(R.applyLifecycle(hash, { type: 'remote-failed' }));
183
+ } else {
184
+ yield fx.timer.set(`register:${hash}`, backoffMs(attempts), { type: 'RegisterRemote', hash, retry: true });
185
+ }
186
+ } finally {
187
+ const stillThere = (yield fx.state.read((s) => s.queries.has(hash))) as boolean;
188
+ if (stillThere) yield fx.state.update(R.applyLifecycle(hash, { type: 'fetch-end' }));
189
+ }
190
+ }
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildRenderIds, 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
+ });