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

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 (303) hide show
  1. package/AGENTS.md +58 -0
  2. package/coverage/lcov-report/base.css +224 -0
  3. package/coverage/lcov-report/block-navigation.js +87 -0
  4. package/coverage/lcov-report/favicon.png +0 -0
  5. package/coverage/lcov-report/index.html +476 -0
  6. package/coverage/lcov-report/prettify.css +1 -0
  7. package/coverage/lcov-report/prettify.js +2 -0
  8. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  9. package/coverage/lcov-report/sorter.js +210 -0
  10. package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +271 -0
  11. package/coverage/lcov-report/src/boot/boot.saga.ts.html +469 -0
  12. package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +430 -0
  13. package/coverage/lcov-report/src/boot/index.html +161 -0
  14. package/coverage/lcov-report/src/boot/preload.saga.ts.html +178 -0
  15. package/coverage/lcov-report/src/client/bucket-handle.ts.html +766 -0
  16. package/coverage/lcov-report/src/client/index.html +161 -0
  17. package/coverage/lcov-report/src/client/router.ts.html +367 -0
  18. package/coverage/lcov-report/src/client/runtime.ts.html +940 -0
  19. package/coverage/lcov-report/src/client/sp00ky-client.ts.html +1531 -0
  20. package/coverage/lcov-report/src/events/index.html +116 -0
  21. package/coverage/lcov-report/src/events/index.ts.html +877 -0
  22. package/coverage/lcov-report/src/index.html +146 -0
  23. package/coverage/lcov-report/src/index.ts.html +202 -0
  24. package/coverage/lcov-report/src/kernel/constants.ts.html +247 -0
  25. package/coverage/lcov-report/src/kernel/effects.ts.html +562 -0
  26. package/coverage/lcov-report/src/kernel/events.ts.html +250 -0
  27. package/coverage/lcov-report/src/kernel/index.html +176 -0
  28. package/coverage/lcov-report/src/kernel/interpreter.ts.html +514 -0
  29. package/coverage/lcov-report/src/kernel/saga.ts.html +304 -0
  30. package/coverage/lcov-report/src/modules/app-release/index.html +116 -0
  31. package/coverage/lcov-report/src/modules/app-release/index.ts.html +712 -0
  32. package/coverage/lcov-report/src/modules/auth/events/index.html +116 -0
  33. package/coverage/lcov-report/src/modules/auth/events/index.ts.html +142 -0
  34. package/coverage/lcov-report/src/modules/auth/index.html +116 -0
  35. package/coverage/lcov-report/src/modules/auth/index.ts.html +1225 -0
  36. package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +967 -0
  37. package/coverage/lcov-report/src/modules/crdt/index.html +146 -0
  38. package/coverage/lcov-report/src/modules/crdt/index.ts.html +1474 -0
  39. package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +160 -0
  40. package/coverage/lcov-report/src/modules/devtools/flags.ts.html +1132 -0
  41. package/coverage/lcov-report/src/modules/devtools/index.html +161 -0
  42. package/coverage/lcov-report/src/modules/devtools/index.ts.html +2413 -0
  43. package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +589 -0
  44. package/coverage/lcov-report/src/modules/devtools/versions.ts.html +415 -0
  45. package/coverage/lcov-report/src/modules/feature-flag/index.html +116 -0
  46. package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +1033 -0
  47. package/coverage/lcov-report/src/modules/index.html +116 -0
  48. package/coverage/lcov-report/src/modules/ref-tables.ts.html +349 -0
  49. package/coverage/lcov-report/src/mutation/index.html +191 -0
  50. package/coverage/lcov-report/src/mutation/jobs.ts.html +208 -0
  51. package/coverage/lcov-report/src/mutation/mutation-id.ts.html +190 -0
  52. package/coverage/lcov-report/src/mutation/push.saga.ts.html +730 -0
  53. package/coverage/lcov-report/src/mutation/rows.ts.html +1216 -0
  54. package/coverage/lcov-report/src/mutation/tray.saga.ts.html +229 -0
  55. package/coverage/lcov-report/src/mutation/write.saga.ts.html +583 -0
  56. package/coverage/lcov-report/src/otel/index.html +116 -0
  57. package/coverage/lcov-report/src/otel/index.ts.html +466 -0
  58. package/coverage/lcov-report/src/query/env.ts.html +253 -0
  59. package/coverage/lcov-report/src/query/fetch.saga.ts.html +412 -0
  60. package/coverage/lcov-report/src/query/hash.ts.html +139 -0
  61. package/coverage/lcov-report/src/query/index.html +281 -0
  62. package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +388 -0
  63. package/coverage/lcov-report/src/query/materialize.saga.ts.html +310 -0
  64. package/coverage/lcov-report/src/query/materialize.ts.html +217 -0
  65. package/coverage/lcov-report/src/query/membership.saga.ts.html +709 -0
  66. package/coverage/lcov-report/src/query/membership.ts.html +559 -0
  67. package/coverage/lcov-report/src/query/register.saga.ts.html +655 -0
  68. package/coverage/lcov-report/src/query/render-set.ts.html +265 -0
  69. package/coverage/lcov-report/src/query/sql.ts.html +322 -0
  70. package/coverage/lcov-report/src/query/window-query.ts.html +670 -0
  71. package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +1894 -0
  72. package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +766 -0
  73. package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +1162 -0
  74. package/coverage/lcov-report/src/services/blobs/index.html +161 -0
  75. package/coverage/lcov-report/src/services/blobs/index.ts.html +295 -0
  76. package/coverage/lcov-report/src/services/database/cache-engine.ts.html +664 -0
  77. package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +1330 -0
  78. package/coverage/lcov-report/src/services/database/database.ts.html +658 -0
  79. package/coverage/lcov-report/src/services/database/engine-factory.ts.html +184 -0
  80. package/coverage/lcov-report/src/services/database/errors.ts.html +187 -0
  81. package/coverage/lcov-report/src/services/database/events/index.html +116 -0
  82. package/coverage/lcov-report/src/services/database/events/index.ts.html +184 -0
  83. package/coverage/lcov-report/src/services/database/index.html +386 -0
  84. package/coverage/lcov-report/src/services/database/index.ts.html +121 -0
  85. package/coverage/lcov-report/src/services/database/local-migrator.ts.html +703 -0
  86. package/coverage/lcov-report/src/services/database/local.ts.html +1633 -0
  87. package/coverage/lcov-report/src/services/database/plan-render.ts.html +409 -0
  88. package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +682 -0
  89. package/coverage/lcov-report/src/services/database/remote.ts.html +1036 -0
  90. package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +4132 -0
  91. package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +220 -0
  92. package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +577 -0
  93. package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +499 -0
  94. package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +478 -0
  95. package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +757 -0
  96. package/coverage/lcov-report/src/services/database/surql-translate.ts.html +1333 -0
  97. package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +568 -0
  98. package/coverage/lcov-report/src/services/logger/index.html +116 -0
  99. package/coverage/lcov-report/src/services/logger/index.ts.html +154 -0
  100. package/coverage/lcov-report/src/services/persistence/index.html +146 -0
  101. package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +163 -0
  102. package/coverage/lcov-report/src/services/persistence/resilient.ts.html +208 -0
  103. package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +271 -0
  104. package/coverage/lcov-report/src/services/stream-processor/index.html +146 -0
  105. package/coverage/lcov-report/src/services/stream-processor/index.ts.html +3313 -0
  106. package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +244 -0
  107. package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +346 -0
  108. package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +934 -0
  109. package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +1984 -0
  110. package/coverage/lcov-report/src/services/tabs/index.html +191 -0
  111. package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +310 -0
  112. package/coverage/lcov-report/src/services/tabs/protocol.ts.html +871 -0
  113. package/coverage/lcov-report/src/services/tabs/support.ts.html +193 -0
  114. package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +2005 -0
  115. package/coverage/lcov-report/src/sp00ky.ts.html +100 -0
  116. package/coverage/lcov-report/src/state/client-state.ts.html +637 -0
  117. package/coverage/lcov-report/src/state/index.html +161 -0
  118. package/coverage/lcov-report/src/state/lifecycle.ts.html +343 -0
  119. package/coverage/lcov-report/src/state/reducers.ts.html +1471 -0
  120. package/coverage/lcov-report/src/state/selectors.ts.html +637 -0
  121. package/coverage/lcov-report/src/sync/connection.saga.ts.html +343 -0
  122. package/coverage/lcov-report/src/sync/index.html +176 -0
  123. package/coverage/lcov-report/src/sync/live.saga.ts.html +232 -0
  124. package/coverage/lcov-report/src/sync/policy.ts.html +1606 -0
  125. package/coverage/lcov-report/src/sync/poll.saga.ts.html +193 -0
  126. package/coverage/lcov-report/src/sync/tabs.saga.ts.html +361 -0
  127. package/coverage/lcov-report/src/types.ts.html +2419 -0
  128. package/coverage/lcov-report/src/utils/blurhash.ts.html +355 -0
  129. package/coverage/lcov-report/src/utils/error-classification.ts.html +244 -0
  130. package/coverage/lcov-report/src/utils/index.html +191 -0
  131. package/coverage/lcov-report/src/utils/index.ts.html +820 -0
  132. package/coverage/lcov-report/src/utils/parser.ts.html +313 -0
  133. package/coverage/lcov-report/src/utils/semver.ts.html +175 -0
  134. package/coverage/lcov-report/src/utils/surql.ts.html +667 -0
  135. package/coverage/lcov.info +19079 -0
  136. package/dist/index.d.ts +2946 -140
  137. package/dist/index.js +13424 -2356
  138. package/dist/otel/index.d.ts +2 -2
  139. package/dist/otel/index.js +6 -6
  140. package/dist/sqlite-open.js +322 -0
  141. package/dist/sqlite-worker.d.ts +1 -0
  142. package/dist/sqlite-worker.js +439 -0
  143. package/dist/tabs-broker-worker.d.ts +8 -0
  144. package/dist/tabs-broker-worker.js +472 -0
  145. package/dist/types.d.ts +769 -11
  146. package/package.json +11 -7
  147. package/scripts/check-broker-bundle.mjs +33 -0
  148. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  149. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  150. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  151. package/src/bucket-blurhash.test.ts +148 -0
  152. package/src/build-globals.d.ts +12 -0
  153. package/src/events/events.test.ts +2 -1
  154. package/src/events/index.ts +3 -0
  155. package/src/index.ts +36 -2
  156. package/src/modules/app-release/index.test.ts +125 -0
  157. package/src/modules/app-release/index.ts +201 -0
  158. package/src/modules/auth/auth.local-first.test.ts +141 -0
  159. package/src/modules/auth/events/index.ts +2 -1
  160. package/src/modules/auth/index.ts +138 -25
  161. package/src/modules/cache/cache.relay.test.ts +95 -0
  162. package/src/modules/cache/index.ts +163 -43
  163. package/src/modules/cache/types.ts +2 -2
  164. package/src/modules/crdt/crdt-field.ts +294 -0
  165. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  166. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  167. package/src/modules/crdt/index.ts +463 -0
  168. package/src/modules/crdt/loro-loader.ts +25 -0
  169. package/src/modules/data/data.activity.test.ts +109 -0
  170. package/src/modules/data/data.authority.test.ts +122 -0
  171. package/src/modules/data/data.heartbeat-wake.test.ts +115 -0
  172. package/src/modules/data/data.hydration.test.ts +142 -0
  173. package/src/modules/data/data.membership.test.ts +623 -0
  174. package/src/modules/data/data.notify-table.test.ts +41 -0
  175. package/src/modules/data/data.pending-ids.test.ts +199 -0
  176. package/src/modules/data/data.rebind.test.ts +170 -0
  177. package/src/modules/data/data.rematerialize.test.ts +114 -0
  178. package/src/modules/data/data.run.test.ts +113 -0
  179. package/src/modules/data/data.settled-writes.test.ts +235 -0
  180. package/src/modules/data/data.status.test.ts +249 -0
  181. package/src/modules/data/id-set-plan.test.ts +122 -0
  182. package/src/modules/data/index.ts +2031 -150
  183. package/src/modules/data/mutation-id.test.ts +25 -0
  184. package/src/modules/data/mutation-id.ts +35 -0
  185. package/src/modules/data/window-query.test.ts +52 -0
  186. package/src/modules/data/window-query.ts +194 -0
  187. package/src/modules/devtools/flags.ts +349 -0
  188. package/src/modules/devtools/index.ts +451 -46
  189. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  190. package/src/modules/devtools/state-shape.test.ts +146 -0
  191. package/src/modules/devtools/storage-info.test.ts +79 -0
  192. package/src/modules/devtools/storage-info.ts +168 -0
  193. package/src/modules/devtools/versions.test.ts +74 -0
  194. package/src/modules/devtools/versions.ts +110 -0
  195. package/src/modules/feature-flag/index.test.ts +251 -0
  196. package/src/modules/feature-flag/index.ts +308 -0
  197. package/src/modules/ref-tables.test.ts +91 -0
  198. package/src/modules/ref-tables.ts +88 -0
  199. package/src/modules/sync/engine.ts +170 -86
  200. package/src/modules/sync/events/index.ts +9 -2
  201. package/src/modules/sync/queue/queue-down.test.ts +228 -0
  202. package/src/modules/sync/queue/queue-down.ts +108 -13
  203. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  204. package/src/modules/sync/queue/queue-up.ts +241 -57
  205. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  206. package/src/modules/sync/scheduler.retry.test.ts +237 -0
  207. package/src/modules/sync/scheduler.ts +215 -13
  208. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  209. package/src/modules/sync/sync.health.test.ts +167 -0
  210. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  211. package/src/modules/sync/sync.list-ref-meta.test.ts +193 -0
  212. package/src/modules/sync/sync.live-removal.test.ts +289 -0
  213. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  214. package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +93 -0
  215. package/src/modules/sync/sync.settled-poll.test.ts +58 -0
  216. package/src/modules/sync/sync.subquery.test.ts +82 -0
  217. package/src/modules/sync/sync.tabs.test.ts +249 -0
  218. package/src/modules/sync/sync.ts +2350 -133
  219. package/src/modules/sync/utils.test.ts +322 -2
  220. package/src/modules/sync/utils.ts +286 -16
  221. package/src/otel/index.ts +13 -10
  222. package/src/services/blobs/blob-cache.test.ts +359 -0
  223. package/src/services/blobs/blob-cache.ts +603 -0
  224. package/src/services/blobs/blob-manifest.ts +227 -0
  225. package/src/services/blobs/blob-store.test.ts +77 -0
  226. package/src/services/blobs/blob-store.ts +359 -0
  227. package/src/services/blobs/blob.fixture.ts +90 -0
  228. package/src/services/blobs/index.ts +70 -0
  229. package/src/services/database/cache-engine.ts +193 -0
  230. package/src/services/database/connection-supervisor.test.ts +289 -0
  231. package/src/services/database/connection-supervisor.ts +415 -0
  232. package/src/services/database/database.query-timeout.test.ts +140 -0
  233. package/src/services/database/database.ts +130 -62
  234. package/src/services/database/engine-factory.ts +33 -0
  235. package/src/services/database/errors.ts +34 -0
  236. package/src/services/database/events/index.ts +2 -1
  237. package/src/services/database/index.ts +7 -0
  238. package/src/services/database/local-migrator.ts +30 -27
  239. package/src/services/database/local.test.ts +64 -0
  240. package/src/services/database/local.ts +484 -67
  241. package/src/services/database/plan-render.test.ts +159 -0
  242. package/src/services/database/plan-render.ts +108 -0
  243. package/src/services/database/relation-resolver.test.ts +467 -0
  244. package/src/services/database/relation-resolver.ts +0 -0
  245. package/src/services/database/remote.ts +222 -15
  246. package/src/services/database/sqlite-cache-engine.test.ts +616 -0
  247. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  248. package/src/services/database/sqlite-cache-engine.ts +1358 -0
  249. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  250. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  251. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  252. package/src/services/database/sqlite-lock-verify.ts +45 -0
  253. package/src/services/database/sqlite-open.test.ts +150 -0
  254. package/src/services/database/sqlite-open.ts +164 -0
  255. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  256. package/src/services/database/sqlite-plan-sql.ts +138 -0
  257. package/src/services/database/sqlite-projection.test.ts +99 -0
  258. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  259. package/src/services/database/sqlite-select.test.ts +246 -0
  260. package/src/services/database/sqlite-select.ts +131 -0
  261. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  262. package/src/services/database/sqlite-transport.ts +224 -0
  263. package/src/services/database/sqlite-worker.ts +437 -0
  264. package/src/services/database/surql-translate.ts +416 -0
  265. package/src/services/database/surreal-cache-engine.ts +161 -0
  266. package/src/services/logger/index.ts +3 -2
  267. package/src/services/persistence/localstorage.ts +2 -2
  268. package/src/services/persistence/resilient.ts +11 -4
  269. package/src/services/persistence/surrealdb.ts +10 -10
  270. package/src/services/stream-processor/index.ts +796 -84
  271. package/src/services/stream-processor/permissions.test.ts +47 -0
  272. package/src/services/stream-processor/permissions.ts +53 -0
  273. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  274. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  275. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  276. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  277. package/src/services/stream-processor/wasm-types.ts +59 -3
  278. package/src/services/tabs/broker-client.ts +283 -0
  279. package/src/services/tabs/broker.test.ts +327 -0
  280. package/src/services/tabs/coordinator.test.ts +365 -0
  281. package/src/services/tabs/coordinator.ts +633 -0
  282. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  283. package/src/services/tabs/leader-locks.ts +75 -0
  284. package/src/services/tabs/protocol.ts +258 -0
  285. package/src/services/tabs/support.ts +36 -0
  286. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  287. package/src/sp00ky.auth-order.test.ts +112 -0
  288. package/src/sp00ky.init-query.test.ts +183 -0
  289. package/src/sp00ky.local-first.test.ts +60 -0
  290. package/src/sp00ky.ts +1854 -0
  291. package/src/types.ts +548 -13
  292. package/src/utils/blurhash.ts +90 -0
  293. package/src/utils/error-classification.test.ts +54 -0
  294. package/src/utils/error-classification.ts +25 -0
  295. package/src/utils/index.ts +79 -13
  296. package/src/utils/parser.test.ts +49 -120
  297. package/src/utils/parser.ts +32 -2
  298. package/src/utils/semver.test.ts +32 -0
  299. package/src/utils/semver.ts +30 -0
  300. package/src/utils/surql.ts +30 -18
  301. package/src/utils/withRetry.test.ts +1 -1
  302. package/tsdown.config.ts +86 -1
  303. package/src/spooky.ts +0 -395
@@ -1,28 +1,38 @@
1
1
  import { RecordId, Duration } from 'surrealdb';
2
- import {
2
+ import type {
3
3
  SchemaStructure,
4
4
  TableNames,
5
5
  BackendNames,
6
6
  BackendRoutes,
7
7
  RoutePayload,
8
+ QueryPlan,
8
9
  } from '@spooky-sync/query-builder';
9
- import { LocalDatabaseService } from '../../services/database/index';
10
- import { CacheModule, RecordWithId } from '../cache/index';
11
- import { Logger } from '../../services/logger/index';
12
- import { StreamUpdate } from '../../services/stream-processor/index';
13
- import {
14
- MutationEvent,
10
+ import type { LocalStore } from '../../services/database/index';
11
+ import { StaleEpochError } from '../../services/database/index';
12
+ import type { CacheModule, RecordWithId, CacheRecord } from '../cache/index';
13
+ import type { Logger } from '../../services/logger/index';
14
+ import type { StreamUpdate } from '../../services/stream-processor/index';
15
+ import type {
15
16
  QueryConfig,
16
17
  QueryHash,
17
18
  QueryState,
19
+ QueryStatus,
20
+ QueryStatusCallback,
18
21
  QueryTimeToLive,
19
22
  QueryUpdateCallback,
20
23
  MutationCallback,
21
24
  RecordVersionArray,
22
25
  QueryConfigRecord,
23
26
  UpdateOptions,
27
+ QueryTimings,
28
+ PhaseStat,
29
+ RegistrationTimings,
24
30
  RunOptions,
31
+ ServerViewMeta,
32
+ MembershipOutcome,
33
+ QueryAuthorityCallback,
25
34
  } from '../../types';
35
+ import { MATERIALIZATION_SAMPLE_WINDOW } from '../../types';
26
36
  import {
27
37
  parseRecordIdString,
28
38
  extractIdPart,
@@ -31,11 +41,37 @@ import {
31
41
  withRetry,
32
42
  surql,
33
43
  parseParams,
44
+ parseQueryParams,
45
+ cleanRecord,
34
46
  extractTablePart,
35
47
  generateId,
36
48
  } from '../../utils/index';
37
- import { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
38
- import { PushEventOptions } from '../../events/index';
49
+ import type { CreateEvent, DeleteEvent, UpdateEvent } from '../sync/index';
50
+ import type { PushEventOptions } from '../../events/index';
51
+ import {
52
+ buildIdSetPlan,
53
+ buildIdSetSurql,
54
+ buildWindowMaterialization,
55
+ buildWindowMaterializationPlan,
56
+ } from './window-query';
57
+ import { mintMutationId } from './mutation-id';
58
+
59
+
60
+ /** Push a timing sample (ms) into a rolling window, capped at the sample window. */
61
+ function pushSample(samples: number[], ms: number): void {
62
+ samples.push(ms);
63
+ if (samples.length > MATERIALIZATION_SAMPLE_WINDOW) samples.shift();
64
+ }
65
+
66
+ /** Build a {lastMs,p50,p90,p99,count} summary from a rolling sample window. */
67
+ function phaseStatOf(samples: number[], lastMs: number | null): PhaseStat {
68
+ if (samples.length === 0) {
69
+ return { lastMs, p50: null, p90: null, p99: null, count: 0 };
70
+ }
71
+ const sorted = [...samples].sort((a, b) => a - b);
72
+ const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
73
+ return { lastMs, p50: pick(0.5), p90: pick(0.9), p99: pick(0.99), count: samples.length };
74
+ }
39
75
 
40
76
  /**
41
77
  * DataModule - Unified query and mutation management
@@ -43,26 +79,153 @@ import { PushEventOptions } from '../../events/index';
43
79
  * Merges the functionality of QueryManager and MutationManager.
44
80
  * Uses CacheModule for all storage operations.
45
81
  */
82
+ /** A `_00_window` row as read back: the id-set and whether the server vouched
83
+ * for it (which is what allows an empty set to count as known membership). */
84
+ export interface DurableMembership {
85
+ ids: RecordVersionArray;
86
+ confirmed: boolean;
87
+ }
88
+
89
+ /**
90
+ * Fraction of a query's TTL after which the client refreshes `lastActiveAt`.
91
+ *
92
+ * Must leave room for at least one retry: the server sweeps a view — and its
93
+ * `_00_list_ref` edges — the moment `lastActiveAt + ttl` passes, and the client
94
+ * is LIVE on those edges, so a swept-but-still-watched view empties the UI.
95
+ */
96
+ const TTL_HEARTBEAT_FRACTION = 0.5;
97
+ /** A query beaten this recently is skipped by `heartbeatOnWake`. */
98
+ const WAKE_HEARTBEAT_MIN_GAP_MS = 30_000;
99
+
46
100
  export class DataModule<S extends SchemaStructure> {
101
+ /** Tab identity baked into mutation ids (shared-tabs rollback routing);
102
+ * undefined in solo mode, where mutation-id falls back to a session id. */
103
+ private tabId: string | undefined;
47
104
  private activeQueries: Map<QueryHash, QueryState> = new Map();
48
105
  private pendingQueries: Map<QueryHash, Promise<QueryHash>> = new Map();
49
106
  private subscriptions: Map<QueryHash, Set<QueryUpdateCallback>> = new Map();
107
+ private statusSubscriptions: Map<QueryHash, Set<QueryStatusCallback>> = new Map();
108
+ /** Observers of a query's authority (`membershipKnown`) flips, keyed like `statusSubscriptions`. */
109
+ private authoritySubscriptions: Map<QueryHash, Set<QueryAuthorityCallback>> = new Map();
50
110
  private mutationCallbacks: Set<MutationCallback> = new Set();
51
111
  private debounceTimers: Map<QueryHash, NodeJS.Timeout> = new Map();
112
+ // The update each debounce timer would process on its trailing edge. Kept in a
113
+ // map (not just the timer closure) so `flushPendingStreamUpdate` can process
114
+ // it early — the sync engine flushes before flipping a query to `idle`, so
115
+ // subscribers never observe idle status with stale (partial-window) rows.
116
+ private pendingStreamUpdates: Map<QueryHash, StreamUpdate> = new Map();
117
+ // Refcount of in-flight fetch cycles per query (registration + concurrent
118
+ // poll/LIVE sync rounds can overlap). Status flips to `fetching` on 0→1 and
119
+ // back to `idle` only on 1→0, so an inner cycle finishing can't emit a
120
+ // premature idle mid-registration.
121
+ private fetchDepth: Map<QueryHash, number> = new Map();
122
+ /**
123
+ * Observers of the AGGREGATE fetch activity: how many queries are mid-fetch
124
+ * right now. What a "downloading" indicator in the app chrome hangs off,
125
+ * instead of subscribing to every query's status one by one.
126
+ */
127
+ private activitySubscriptions: Set<(fetching: number) => void> = new Set();
52
128
  private logger: Logger;
129
+ /**
130
+ * Optional observer notified whenever a query's fetch status changes.
131
+ * Wired by Sp00kyClient to push status changes into DevTools. Kept as a
132
+ * settable field (rather than a constructor arg) because DevTools is
133
+ * constructed after DataModule.
134
+ */
135
+ public onQueryStatusChange?: (hash: QueryHash, status: QueryStatus) => void;
136
+ /**
137
+ * Optional observer invoked when a still-subscribed query's TTL heartbeat
138
+ * fires (~90% of the TTL). Wired by Sp00kyClient to
139
+ * `Sp00kySync.heartbeatQuery`, which refreshes the remote `_00_query`
140
+ * row's `lastActiveAt` so an actively-watched query never expires. Settable
141
+ * field (not a constructor arg) because the sync engine is wired after
142
+ * DataModule is constructed — mirrors `onQueryStatusChange`.
143
+ */
144
+ public onHeartbeat?: (hash: QueryHash) => void;
145
+ /**
146
+ * Fired once per episode when the server's `_00_query` row for a query we
147
+ * hold membership for is found missing (see `updateQueryRemoteArray`). Wired
148
+ * by Sp00kyClient to enqueue a `register` down-event so the SSP rebuilds the
149
+ * view; the client keeps rendering what it has meanwhile.
150
+ */
151
+ public onViewLost?: (hash: QueryHash) => void;
152
+ /** DevTools observer for authority flips; mirrors `onQueryStatusChange`. */
153
+ public onQueryAuthorityChange?: (hash: QueryHash, known: boolean) => void;
154
+ /**
155
+ * Optional hook fired by {@link deregisterQuery} when an opt-in query (e.g. a
156
+ * viewport-windowed list cancelling an off-screen window) loses its last
157
+ * subscriber. Wired by Sp00kyClient to enqueue a `cleanup` down-event, which
158
+ * tears the remote `_00_query` view down (releasing its `_00_list_ref` edges)
159
+ * instead of leaving it for the TTL sweep. The local view + state are freed in
160
+ * {@link finalizeDeregister} only after that remote delete, so a fast
161
+ * re-subscribe (scroll back) can abort/heal the teardown — see `cleanupQuery`.
162
+ */
163
+ public onDeregister?: (hash: QueryHash) => void;
164
+ // Salt for query-id hashing. Set from SurrealDB's session::id() so two
165
+ // browser sessions registering the same logical query (same surql + params)
166
+ // don't collide on the same `_00_query` row — each session gets its own.
167
+ // Empty string until init(sessionId) is called.
168
+ private sessionId: string = '';
169
+ // Authenticated user record id (e.g. `"user:abc"`). Updated by
170
+ // `setCurrentUserId` from the auth subscription. null when
171
+ // unauthenticated. Consulted by `Sp00kySync.listRefTable()` so the
172
+ // poll and LIVE subscription target the same per-user
173
+ // `_00_list_ref_user_<id>` table the sync engine writes to.
174
+ private currentUserId: string | null = null;
53
175
 
54
176
  constructor(
55
177
  private cache: CacheModule,
56
- private local: LocalDatabaseService,
178
+ private local: LocalStore,
57
179
  private schema: S,
58
180
  logger: Logger,
59
- private streamDebounceTime: number = 100
181
+ // Client-side SSP aggregation throttle: coalesces the in-browser
182
+ // StreamProcessor's per-record stream updates per query before notifying
183
+ // readers, so a burst of synced records repaints the UI once per window
184
+ // rather than row-by-row. 50ms keeps it responsive while still batching the
185
+ // initial-sync stream. Override via `SyncedDbConfig.streamDebounceTime`.
186
+ private streamDebounceTime: number = 50
60
187
  ) {
61
188
  this.logger = logger.child({ service: 'DataModule' });
62
189
  }
63
190
 
64
- async init(): Promise<void> {
65
- this.logger.info({ Category: 'spooky-client::DataModule::init' }, 'DataModule initialized');
191
+ async init(sessionId: string): Promise<void> {
192
+ this.sessionId = sessionId;
193
+ this.logger.info(
194
+ { sessionId, Category: 'sp00ky-client::DataModule::init' },
195
+ 'DataModule initialized'
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Update the session salt used in query-id hashing. Call this when the
201
+ * SurrealDB session changes (sign-in, sign-out, reconnect). Subsequently
202
+ * registered queries will get fresh, session-scoped IDs.
203
+ */
204
+ setSessionId(sessionId: string): void {
205
+ this.sessionId = sessionId;
206
+ }
207
+
208
+ /** Shared-tabs: bake this tab's identity into mutation ids so a rollback of
209
+ * a follower's mutation routes back to the tab that made it. */
210
+ setTabId(tabId: string): void {
211
+ this.tabId = tabId;
212
+ }
213
+
214
+ /**
215
+ * Update the authenticated user record id. Pass `null` on sign-out.
216
+ * Read by `Sp00kySync.listRefTable()` so the LIVE subscription and
217
+ * the poll route to the same per-user `_00_list_ref_user_<id>` the
218
+ * SSP writes to.
219
+ */
220
+ setCurrentUserId(userId: string | null): void {
221
+ this.currentUserId = userId;
222
+ }
223
+
224
+ /** Read-only view of the authenticated user id used for per-user
225
+ * `_00_list_ref` routing. Other modules consult this so they pick the
226
+ * same table name DataModule does. */
227
+ getCurrentUserId(): string | null {
228
+ return this.currentUserId;
66
229
  }
67
230
 
68
231
  // ==================== QUERY MANAGEMENT ====================
@@ -74,19 +237,22 @@ export class DataModule<S extends SchemaStructure> {
74
237
  tableName: T,
75
238
  surqlString: string,
76
239
  params: Record<string, any>,
77
- ttl: QueryTimeToLive
240
+ ttl: QueryTimeToLive,
241
+ plan?: QueryPlan
78
242
  ): Promise<QueryHash> {
79
243
  const hash = await this.calculateHash({ surql: surqlString, params });
80
244
  this.logger.debug(
81
- { hash, Category: 'spooky-client::DataModule::query' },
245
+ { hash, Category: 'sp00ky-client::DataModule::query' },
82
246
  'Query Initialization: started'
83
247
  );
84
248
 
85
- const recordId = new RecordId('_spooky_query', hash);
249
+ // `_00_query` stays the single shared registration table in both
250
+ // ref-modes; the per-user split happens only on `_00_list_ref`.
251
+ const recordId = new RecordId('_00_query', hash);
86
252
 
87
253
  if (this.activeQueries.has(hash)) {
88
254
  this.logger.debug(
89
- { hash, Category: 'spooky-client::DataModule::query' },
255
+ { hash, Category: 'sp00ky-client::DataModule::query' },
90
256
  'Query Initialization: exists, returning'
91
257
  );
92
258
  return hash;
@@ -95,7 +261,7 @@ export class DataModule<S extends SchemaStructure> {
95
261
  // Another call is already creating this query — wait for it
96
262
  if (this.pendingQueries.has(hash)) {
97
263
  this.logger.debug(
98
- { hash, Category: 'spooky-client::DataModule::query' },
264
+ { hash, Category: 'sp00ky-client::DataModule::query' },
99
265
  'Query Initialization: pending, waiting for existing creation'
100
266
  );
101
267
  await this.pendingQueries.get(hash);
@@ -103,12 +269,21 @@ export class DataModule<S extends SchemaStructure> {
103
269
  }
104
270
 
105
271
  this.logger.debug(
106
- { hash, Category: 'spooky-client::DataModule::query' },
272
+ { hash, Category: 'sp00ky-client::DataModule::query' },
107
273
  'Query Initialization: not found, creating new query'
108
274
  );
109
275
 
110
276
  // Create the query and track the pending promise
111
- const promise = this.createAndRegisterQuery<T>(hash, recordId, surqlString, params, ttl, tableName);
277
+ const promise = this.createAndRegisterQuery<T>(
278
+ hash,
279
+ recordId,
280
+ surqlString,
281
+ params,
282
+ ttl,
283
+ tableName,
284
+ plan,
285
+ await this.calculateMembershipKey({ surql: surqlString, params })
286
+ );
112
287
  this.pendingQueries.set(hash, promise);
113
288
  try {
114
289
  await promise;
@@ -147,11 +322,189 @@ export class DataModule<S extends SchemaStructure> {
147
322
  subs.delete(callback);
148
323
  if (subs.size === 0) {
149
324
  this.subscriptions.delete(queryHash);
325
+ // NOTE: intentionally do NOT tear down the query / free its in-browser
326
+ // SSP view here. The subscriber-gated heartbeat (startTTLHeartbeat)
327
+ // already self-stops once subscribers hit 0, so an abandoned query
328
+ // stops being kept alive and the server's TTL sweep removes it. Freeing
329
+ // the local view on every last-unsubscribe caused re-registration churn
330
+ // on navigation (open A → leave → open A again re-registers), which is
331
+ // a flakiness risk for no real benefit — keep the local view resident.
150
332
  }
151
333
  }
152
334
  };
153
335
  }
154
336
 
337
+ /**
338
+ * Subscribe to a query's fetch-status changes (idle/fetching).
339
+ * With `{ immediate: true }` the callback fires synchronously with the
340
+ * current status (defaults to `idle` if the query isn't registered yet).
341
+ */
342
+ subscribeStatus(
343
+ queryHash: string,
344
+ callback: QueryStatusCallback,
345
+ options: { immediate?: boolean } = {}
346
+ ): () => void {
347
+ if (!this.statusSubscriptions.has(queryHash)) {
348
+ this.statusSubscriptions.set(queryHash, new Set());
349
+ }
350
+ this.statusSubscriptions.get(queryHash)?.add(callback);
351
+
352
+ if (options.immediate) {
353
+ callback(this.activeQueries.get(queryHash)?.status ?? 'idle');
354
+ }
355
+
356
+ return () => {
357
+ const subs = this.statusSubscriptions.get(queryHash);
358
+ if (subs) {
359
+ subs.delete(callback);
360
+ if (subs.size === 0) {
361
+ this.statusSubscriptions.delete(queryHash);
362
+ }
363
+ }
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Subscribe to a query's authority flips: whether server membership is known
369
+ * for it. With `{ immediate: true }` the callback fires synchronously with
370
+ * the current value (`false` for an unknown query).
371
+ */
372
+ subscribeAuthority(
373
+ queryHash: string,
374
+ callback: QueryAuthorityCallback,
375
+ options: { immediate?: boolean } = {}
376
+ ): () => void {
377
+ if (!this.authoritySubscriptions.has(queryHash)) {
378
+ this.authoritySubscriptions.set(queryHash, new Set());
379
+ }
380
+ this.authoritySubscriptions.get(queryHash)?.add(callback);
381
+
382
+ if (options.immediate) {
383
+ callback(this.activeQueries.get(queryHash)?.config.membershipKnown === true);
384
+ }
385
+
386
+ return () => {
387
+ const subs = this.authoritySubscriptions.get(queryHash);
388
+ if (subs) {
389
+ subs.delete(callback);
390
+ if (subs.size === 0) {
391
+ this.authoritySubscriptions.delete(queryHash);
392
+ }
393
+ }
394
+ };
395
+ }
396
+
397
+ /** Whether server membership is known for `queryHash` (see `QueryConfig.membershipKnown`). */
398
+ isAuthoritative(queryHash: string): boolean {
399
+ return this.activeQueries.get(queryHash)?.config.membershipKnown === true;
400
+ }
401
+
402
+ /**
403
+ * The single writer of `config.membershipKnown` for a live query. Fans out
404
+ * to `subscribeAuthority` listeners and DevTools on a change; no-op when
405
+ * unchanged or the query is unknown.
406
+ */
407
+ private setMembershipKnown(queryHash: string, known: boolean): void {
408
+ const queryState = this.activeQueries.get(queryHash);
409
+ if (!queryState) return;
410
+ const was = queryState.config.membershipKnown === true;
411
+ queryState.config.membershipKnown = known;
412
+ if (was === known) return;
413
+ this.onQueryAuthorityChange?.(queryHash, known);
414
+ const subs = this.authoritySubscriptions.get(queryHash);
415
+ if (subs) {
416
+ for (const callback of subs) {
417
+ callback(known);
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Set a query's fetch status and notify status observers (DevTools +
424
+ * `subscribeStatus` listeners). No-op when the status is unchanged or the
425
+ * query is unknown.
426
+ */
427
+ setQueryStatus(queryHash: string, status: QueryStatus): void {
428
+ const queryState = this.activeQueries.get(queryHash);
429
+ if (!queryState || queryState.status === status) {
430
+ return;
431
+ }
432
+ queryState.status = status;
433
+
434
+ this.onQueryStatusChange?.(queryHash, status);
435
+
436
+ const subs = this.statusSubscriptions.get(queryHash);
437
+ if (subs) {
438
+ for (const callback of subs) {
439
+ callback(status);
440
+ }
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Enter a fetch cycle for a query. Refcounted: registration and concurrent
446
+ * poll/LIVE sync rounds can overlap on the same hash, and only the OUTERMOST
447
+ * cycle may flip the status — 0→1 emits `fetching`, and `endFetching`'s 1→0
448
+ * emits `idle`. Always pair with `endFetching` in a `finally`.
449
+ */
450
+ beginFetching(queryHash: string): void {
451
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
452
+ this.fetchDepth.set(queryHash, depth + 1);
453
+ if (depth === 0) {
454
+ this.setQueryStatus(queryHash, 'fetching');
455
+ this.notifyActivity();
456
+ }
457
+ }
458
+
459
+ /** Leave a fetch cycle started with {@link beginFetching}; emits `idle` on the last exit. */
460
+ endFetching(queryHash: string): void {
461
+ const depth = this.fetchDepth.get(queryHash) ?? 0;
462
+ if (depth <= 1) {
463
+ this.fetchDepth.delete(queryHash);
464
+ this.setQueryStatus(queryHash, 'idle');
465
+ this.notifyActivity();
466
+ return;
467
+ }
468
+ this.fetchDepth.set(queryHash, depth - 1);
469
+ }
470
+
471
+ /** How many queries are mid-fetch right now (see {@link subscribeActivity}). */
472
+ get fetchingQueryCount(): number {
473
+ return this.fetchDepth.size;
474
+ }
475
+
476
+ /**
477
+ * Observe the number of queries currently fetching. Fires on every change of
478
+ * that number (a query entering or leaving its outermost fetch cycle), and
479
+ * synchronously with the current value when `immediate` is set. One
480
+ * subscription per indicator, however many queries the page holds.
481
+ */
482
+ subscribeActivity(
483
+ callback: (fetching: number) => void,
484
+ options: { immediate?: boolean } = {}
485
+ ): () => void {
486
+ this.activitySubscriptions.add(callback);
487
+ if (options.immediate) callback(this.fetchDepth.size);
488
+ return () => {
489
+ this.activitySubscriptions.delete(callback);
490
+ };
491
+ }
492
+
493
+ private notifyActivity(): void {
494
+ if (this.activitySubscriptions.size === 0) return;
495
+ const n = this.fetchDepth.size;
496
+ for (const cb of this.activitySubscriptions) {
497
+ try {
498
+ cb(n);
499
+ } catch (err) {
500
+ this.logger.warn(
501
+ { err, Category: 'sp00ky-client::DataModule::notifyActivity' },
502
+ 'Activity subscriber threw'
503
+ );
504
+ }
505
+ }
506
+ }
507
+
155
508
  /**
156
509
  * Subscribe to mutations (for sync)
157
510
  */
@@ -168,67 +521,487 @@ export class DataModule<S extends SchemaStructure> {
168
521
  async onStreamUpdate(update: StreamUpdate): Promise<void> {
169
522
  const { queryHash, op } = update;
170
523
 
171
- // Only debounce UPDATE operations
172
- // CREATE and DELETE should propagate immediately
173
- if (op === 'UPDATE') {
174
- // Clear existing timer if any
175
- if (this.debounceTimers.has(queryHash)) {
176
- clearTimeout(this.debounceTimers.get(queryHash)!);
524
+ // DELETE propagates immediately — a removed row should disappear without
525
+ // waiting on a debounce.
526
+ //
527
+ // CREATE and UPDATE are coalesced per query on a trailing timer. A list's
528
+ // rows stream in from sync as many small `_00_list_ref` diffs, each its own
529
+ // `cache.saveBatch` → one stream update per chunk. `ingestMany` only
530
+ // coalesces records ingested synchronously together, so chunks spread over
531
+ // time each re-materialize and re-notify — a 50-row window can fire 30+
532
+ // updates as it fills. Each StreamUpdate carries the full materialized
533
+ // `localArray`, so the latest one already reflects every prior chunk: keep
534
+ // only it and fire once on the trailing edge, settling the query in a couple
535
+ // of notifications instead of one per chunk.
536
+ if (op === 'DELETE') {
537
+ const existing = this.debounceTimers.get(queryHash);
538
+ if (existing) {
539
+ clearTimeout(existing);
540
+ this.debounceTimers.delete(queryHash);
177
541
  }
542
+ // The DELETE update carries the full latest localArray, so the coalesced
543
+ // CREATE/UPDATE it supersedes is already reflected — drop it.
544
+ this.pendingStreamUpdates.delete(queryHash);
545
+ await this.processStreamUpdate(update);
546
+ return;
547
+ }
178
548
 
179
- // Set new timer
180
- const timer = setTimeout(async () => {
181
- this.debounceTimers.delete(queryHash);
182
- await this.processStreamUpdate(update);
183
- }, this.streamDebounceTime);
549
+ this.queueStreamUpdate(update);
550
+ }
184
551
 
185
- this.debounceTimers.set(queryHash, timer);
186
- } else {
187
- // CREATE and DELETE - process immediately
552
+ /** Coalesce `update` onto the query's trailing timer (see onStreamUpdate). */
553
+ private queueStreamUpdate(update: StreamUpdate): void {
554
+ const { queryHash } = update;
555
+ // Clear existing timer if any
556
+ if (this.debounceTimers.has(queryHash)) {
557
+ // oxlint-disable-next-line no-non-null-assertion -- guarded by .has() check above
558
+ clearTimeout(this.debounceTimers.get(queryHash)!);
559
+ }
560
+
561
+ // Set new timer
562
+ this.pendingStreamUpdates.set(queryHash, update);
563
+ const timer = setTimeout(async () => {
564
+ this.debounceTimers.delete(queryHash);
565
+ this.pendingStreamUpdates.delete(queryHash);
188
566
  await this.processStreamUpdate(update);
567
+ }, this.streamDebounceTime);
568
+
569
+ this.debounceTimers.set(queryHash, timer);
570
+ }
571
+
572
+ /**
573
+ * Re-materialize + notify a query whose MEMBERSHIP changed without any row
574
+ * needing to be fetched, i.e. without the SSP stream update that normally
575
+ * carries the notify. That is every row this client wrote itself: the local
576
+ * CREATE memoized it at `_00_rv = 1`, the server publishes it at 1, so the
577
+ * sync engine rightly fetches nothing - and then nobody told the subscribers
578
+ * that `remoteArray` now holds the id. The row appeared on reload only.
579
+ *
580
+ * Routed through the same per-query debounce as a real stream update, so it
581
+ * cannot race one: a pending real update already materializes against the
582
+ * current `remoteArray` and wins. The synthetic update re-uses the circuit's
583
+ * last `localArray` and skips the persist/metrics that describe an ingest.
584
+ */
585
+ scheduleRematerialize(queryHash: string): void {
586
+ if (this.debounceTimers.has(queryHash)) return;
587
+ const queryState = this.activeQueries.get(queryHash);
588
+ if (!queryState) return;
589
+ this.queueStreamUpdate({
590
+ queryHash,
591
+ localArray: queryState.config.localArray ?? [],
592
+ op: 'UPDATE',
593
+ synthetic: true,
594
+ });
595
+ }
596
+
597
+ /**
598
+ * Process a query's pending (debounced) stream update NOW instead of on the
599
+ * trailing edge. Called by the sync engine before it flips a query back to
600
+ * `idle`, so the status change never races ahead of the rows it fetched.
601
+ * No-op when nothing is pending. The pending entry is removed before the
602
+ * await so a concurrently-firing timer can't process it twice.
603
+ */
604
+ async flushPendingStreamUpdate(queryHash: string): Promise<boolean> {
605
+ const timer = this.debounceTimers.get(queryHash);
606
+ if (timer) {
607
+ clearTimeout(timer);
608
+ this.debounceTimers.delete(queryHash);
609
+ }
610
+ const pending = this.pendingStreamUpdates.get(queryHash);
611
+ if (!pending) return false;
612
+ this.pendingStreamUpdates.delete(queryHash);
613
+ await this.processStreamUpdate(pending);
614
+ return true;
615
+ }
616
+
617
+ /**
618
+ * Materialize a query's result rows from the local store.
619
+ *
620
+ * A query's rows are its MEMBERSHIP — the id-set the server put in
621
+ * `_00_list_ref` (`remoteArray`) — not "every local body that matches the
622
+ * WHERE". Those two disagree, and the disagreement was the bug: when a row
623
+ * leaves a query's window but still exists upstream, `handleRemovedRecords`
624
+ * keeps its local body and never re-fetches it, so a predicate re-scan finds
625
+ * that stale body still matching and keeps rendering the row. Selecting the
626
+ * id-set directly is also the only correct thing for a windowed query, where
627
+ * re-applying `START m` against the shared local store skips the window's own
628
+ * rows entirely (sparse windowing) and returns nothing.
629
+ *
630
+ * The rendered set is:
631
+ *
632
+ * (membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes
633
+ *
634
+ * The middle term keeps optimistic writes visible without re-admitting stale
635
+ * rows. Every local write is fed to the SSP (`cache.saveBatch` →
636
+ * `ingestMany`), so `localArray` answers "does this row match the predicate
637
+ * per LOCAL truth". A pending write that moves a row into the window is in
638
+ * `localArray` and shows; one that moves a row out is absent and does not; a
639
+ * stale body the server dropped has no pending write at all, so it stays out.
640
+ * `pendingDeletes` covers the reverse lag — the server still lists a row whose
641
+ * DELETE is sitting in our outbox.
642
+ *
643
+ * Falls back to the predicate scan only when membership has never been
644
+ * established (a query first run on this device), so an offline first paint
645
+ * still shows something.
646
+ */
647
+ private async materializeRecords(
648
+ queryState: QueryState,
649
+ sspArray?: Array<[string, number]>
650
+ ): Promise<Record<string, any>[]> {
651
+ const t0 = performance.now();
652
+ const records = await this.materializeFromConfig(queryState.config, sspArray);
653
+ // Local SurrealDB record-fetch time → DevTools "localFetch" phase.
654
+ this.recordPhase(queryState, 'localFetch', performance.now() - t0);
655
+ return records;
656
+ }
657
+
658
+ /**
659
+ * The materialization itself, without the DevTools timing wrapper. Split out so
660
+ * cold-start seeding can use it before a `QueryState` exists.
661
+ */
662
+ private async materializeFromConfig(
663
+ config: QueryConfig,
664
+ sspArray?: Array<[string, number]>
665
+ ): Promise<Record<string, any>[]> {
666
+ const plan = config.plan;
667
+ const membership = this.resolveMembership(config, sspArray);
668
+ let records: Record<string, any>[];
669
+
670
+ if (membership) {
671
+ const ids = await this.buildRenderIds(config, membership, sspArray);
672
+ if (plan) {
673
+ records = await this.local.select(buildIdSetPlan(plan, ids), config.params);
674
+ } else {
675
+ const idSetSurql = buildIdSetSurql(config.surql);
676
+ if (idSetSurql) {
677
+ const [rows] = await this.local.query<[Record<string, any>[]]>(idSetSurql.query, {
678
+ ...config.params,
679
+ __win: ids,
680
+ });
681
+ records = rows || [];
682
+ } else {
683
+ // Unparseable shape (no top-level FROM): the predicate scan is the
684
+ // only option left. Rare — the query-builder always supplies a plan.
685
+ const [rows] = await this.local.query<[Record<string, any>[]]>(
686
+ config.surql,
687
+ config.params
688
+ );
689
+ records = rows || [];
690
+ }
691
+ }
692
+ } else if (plan) {
693
+ records = await this.local.select(plan, config.params);
694
+ } else {
695
+ const [rows] = await this.local.query<[Record<string, any>[]]>(config.surql, config.params);
696
+ records = rows || [];
697
+ }
698
+ return records;
699
+ }
700
+
701
+ /**
702
+ * The authoritative membership list to render from, or `null` when membership
703
+ * has never been established and the caller must fall back to a scan.
704
+ *
705
+ * A windowed query has no usable fallback — re-running its `START m` locally
706
+ * returns the wrong rows — so it renders from whatever id-set is on hand
707
+ * (SSP's included) rather than degrading to a scan. That is the pre-existing
708
+ * behavior for windows and is preserved.
709
+ */
710
+ private resolveMembership(
711
+ config: QueryConfig,
712
+ sspArray?: Array<[string, number]>
713
+ ): RecordVersionArray | null {
714
+ if (config.membershipKnown) return config.remoteArray ?? [];
715
+ if (buildWindowMaterialization(config.surql) !== null) {
716
+ return (
717
+ (config.remoteArray?.length && config.remoteArray) ||
718
+ (sspArray?.length && sspArray) ||
719
+ config.localArray ||
720
+ []
721
+ );
722
+ }
723
+ return null;
724
+ }
725
+
726
+ // ---- Settled-write grace window ----------------------------------------
727
+ //
728
+ // A local write is visible because it is in the outbox: the render set is
729
+ // `(membership ∪ (pendingWrites ∩ localArray)) − pendingDeletes`. The outbox
730
+ // row is deleted the moment the remote push succeeds (see
731
+ // `UpQueue.next` / `SyncScheduler` — the delete is deliberately tied to the
732
+ // push, not to anything downstream), but the row does not enter `membership`
733
+ // until the SSP has ingested it, materialized the view and written the
734
+ // `_00_list_ref` edge, and this client has read that back.
735
+ //
736
+ // Between those two moments the row is in neither term, so it is rendered,
737
+ // then disappears, then returns — reported as "the comment shows on every
738
+ // other client but not on the one that wrote it". Other clients don't blink
739
+ // because a client that never established membership renders from the local
740
+ // predicate scan instead.
741
+ //
742
+ // So a settled write keeps its place in the union for a short grace period,
743
+ // until membership catches up or the deadline passes. Kept in memory only:
744
+ // it covers a round trip, and a reload re-derives membership anyway.
745
+ private readonly settledWrites = new Map<string, number>();
746
+ private readonly settledDeletes = new Map<string, number>();
747
+
748
+ // ---- Outbox id cache -----------------------------------------------------
749
+ //
750
+ // `getPendingRecordIds` runs a full `SELECT ... FROM _00_pending_mutations`,
751
+ // and `buildRenderIds` calls it on EVERY materialization of EVERY query. That
752
+ // is a round trip down the local engine's single-flight op queue, so with tens
753
+ // of live queries one ingest fanned out into tens of them — in a 4-second
754
+ // capture, 11 of 25 local queries were that one statement, against an outbox
755
+ // holding zero rows.
756
+ //
757
+ // Cached, and invalidated at the two points that change the outbox: enqueueing
758
+ // a mutation (below) and `noteWriteSettled` (the row is deleted before it is
759
+ // called). The TTL is a BACKSTOP, not the mechanism: should some other path
760
+ // ever remove a row without telling us, this bounds the staleness to one tick
761
+ // instead of the session. Well under the grace windows the render set already
762
+ // tolerates deliberately (SETTLED_WRITE_GRACE_MS is 10s).
763
+ private pendingIds: { writes: Set<string>; deletes: Set<string> } | null = null;
764
+ private pendingIdsAt = 0;
765
+ private pendingIdsInflight: Promise<{ writes: Set<string>; deletes: Set<string> }> | null = null;
766
+ private static readonly PENDING_IDS_TTL_MS = 250;
767
+ // Bumped by every invalidation. A read carries the generation it started
768
+ // under, and a result from an older generation is neither cached nor
769
+ // returned to a caller that asked after the invalidation: it was taken
770
+ // before the outbox row it needs existed. Without this, a materialization
771
+ // that joined a read already in flight when `create()` invalidated saw the
772
+ // pre-create set, found "no change", and skipped its notify - the one
773
+ // stream update a CREATE gets, so the row stayed invisible until reload.
774
+ private pendingIdsGen = 0;
775
+ private static readonly PENDING_IDS_MAX_REREADS = 3;
776
+
777
+ /** Drop the cached outbox ids. Cheap; call it on anything that could change
778
+ * `_00_pending_mutations`. */
779
+ private invalidatePendingIds(): void {
780
+ this.pendingIds = null;
781
+ this.pendingIdsGen++;
782
+ // The in-flight read (if any) predates this invalidation; whoever joins
783
+ // next starts a fresh one. Its own joiners re-read via the generation check.
784
+ this.pendingIdsInflight = null;
785
+ }
786
+
787
+ /**
788
+ * Grace period for a settled write. Long enough to cover an SSP round trip
789
+ * that is running slowly (seconds, not milliseconds, when the edge path is
790
+ * backed up), short enough that a write the server silently dropped cannot
791
+ * linger misleadingly.
792
+ *
793
+ * The rejection case does NOT rely on this expiring: an application error
794
+ * rolls the mutation back and never reports it settled, so it vanishes at
795
+ * once. This deadline only bounds the case where the write succeeded and its
796
+ * membership never arrived at all.
797
+ *
798
+ * 30 s, up from 10 s. At 10 s every server hiccup longer than a scheduler
799
+ * drain plus one slow database commit showed up as the user's own chat
800
+ * message vanishing and coming back a minute later. 30 s covers a drain and
801
+ * a stalled commit end to end; a membership that takes longer than that is
802
+ * a server fault the admin heartbeat reports, not something to paper over.
803
+ */
804
+ private static readonly SETTLED_WRITE_GRACE_MS = 30_000;
805
+
806
+ /**
807
+ * Whether any settled write or delete is still waiting for membership to
808
+ * catch up. The sync poll keeps its fast cadence while this is true, so a
809
+ * missed LIVE notification for the edge is picked up on the next poll tick
810
+ * rather than after the idle backoff.
811
+ */
812
+ hasSettledWritesPending(): boolean {
813
+ this.pruneSettled(Date.now());
814
+ return this.settledWrites.size > 0 || this.settledDeletes.size > 0;
815
+ }
816
+
817
+ /**
818
+ * Report that a mutation was accepted by the server and its outbox row
819
+ * removed. Called only on the SUCCESS path — a rolled-back mutation must
820
+ * disappear immediately, which is what makes this safe.
821
+ */
822
+ noteWriteSettled(recordId: string, mutationType: string): void {
823
+ // The outbox row is already gone by the time this is called — that is what
824
+ // makes it the delete-side invalidation point for the cache above.
825
+ this.invalidatePendingIds();
826
+ const until = Date.now() + DataModule.SETTLED_WRITE_GRACE_MS;
827
+ if (mutationType === 'delete') this.settledDeletes.set(recordId, until);
828
+ else this.settledWrites.set(recordId, until);
829
+ }
830
+
831
+ /** Drop entries past their deadline. */
832
+ private pruneSettled(now: number): void {
833
+ for (const [id, until] of this.settledWrites) {
834
+ if (until <= now) this.settledWrites.delete(id);
835
+ }
836
+ for (const [id, until] of this.settledDeletes) {
837
+ if (until <= now) this.settledDeletes.delete(id);
838
+ }
839
+ }
840
+
841
+ /** Apply the pending-write union and pending-delete subtraction, and map to
842
+ * RecordIds for the engines' id-set path. */
843
+ private async buildRenderIds(
844
+ config: QueryConfig,
845
+ membership: RecordVersionArray,
846
+ sspArray?: Array<[string, number]>
847
+ ): Promise<unknown[]> {
848
+ const { writes, deletes } = await this.getPendingRecordIds();
849
+ const now = Date.now();
850
+ this.pruneSettled(now);
851
+ // A settled write counts as pending until membership catches up. Merged
852
+ // into the same sets so the union/subtraction below is unchanged — the
853
+ // grace window changes WHEN an id leaves the render set, never how the
854
+ // set is composed.
855
+ if (this.settledWrites.size > 0) {
856
+ for (const id of this.settledWrites.keys()) writes.add(id);
189
857
  }
858
+ if (this.settledDeletes.size > 0) {
859
+ for (const id of this.settledDeletes.keys()) deletes.add(id);
860
+ }
861
+
862
+ const ordered: string[] = [];
863
+ const seen = new Set<string>();
864
+ for (const [id] of membership) {
865
+ // Membership has caught up with this write: the grace window has done
866
+ // its job and ends here rather than at its deadline. Doing it inline
867
+ // keeps the common case (nothing settled) free of extra passes.
868
+ if (this.settledWrites.size > 0) this.settledWrites.delete(id);
869
+ if (deletes.has(id) || seen.has(id)) continue;
870
+ seen.add(id);
871
+ ordered.push(id);
872
+ }
873
+ // A settled DELETE is the mirror case: membership still lists the row
874
+ // until the SSP publishes its removal, so the id stays subtracted until
875
+ // membership stops naming it.
876
+ if (this.settledDeletes.size > 0) {
877
+ const stillListed = new Set(membership.map(([id]) => id));
878
+ for (const id of [...this.settledDeletes.keys()]) {
879
+ if (!stillListed.has(id)) this.settledDeletes.delete(id);
880
+ }
881
+ }
882
+ if (writes.size > 0) {
883
+ // Only pending writes the SSP agrees currently match this query — see the
884
+ // formula in `materializeRecords`. `sspArray` is the fresher signal when a
885
+ // stream update triggered this pass; `localArray` is the persisted one.
886
+ const localView = (sspArray?.length && sspArray) || config.localArray || [];
887
+ for (const [id] of localView) {
888
+ if (!writes.has(id) || deletes.has(id) || seen.has(id)) continue;
889
+ seen.add(id);
890
+ ordered.push(id);
891
+ }
892
+ }
893
+ // `_00_list_ref` is selected without an ORDER BY, so this id-set arrives in
894
+ // whatever order the server happened to return. For a query with its own
895
+ // ORDER BY that does not matter (the engine sorts), and for a window the
896
+ // id-set order IS the window's slice order and must be preserved. Anything
897
+ // else renders in server order while its first paint came from the local
898
+ // scan in id order — the same rows, visibly reshuffled a second later.
899
+ // Sorting here is what makes the two paints agree.
900
+ const hasExplicitOrder = (config.plan?.orderBy?.length ?? 0) > 0;
901
+ const isWindow = buildWindowMaterialization(config.surql) !== null;
902
+ if (!hasExplicitOrder && !isWindow) {
903
+ ordered.sort();
904
+ }
905
+ return ordered.map((id) => parseRecordIdString(id));
190
906
  }
191
907
 
192
908
  private async processStreamUpdate(update: StreamUpdate): Promise<void> {
193
- const { queryHash, localArray } = update;
909
+ const { queryHash, localArray, materializationTimeMs } = update;
194
910
  const queryState = this.activeQueries.get(queryHash);
195
911
  if (!queryState) {
196
912
  this.logger.warn(
197
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
913
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
198
914
  'Received update for unknown query. Skipping...'
199
915
  );
200
916
  return;
201
917
  }
202
918
 
203
- try {
204
- // Fetch updated records
205
- const [records] = await this.local.query<[Record<string, any>[]]>(
206
- queryState.config.surql,
207
- queryState.config.params
208
- );
919
+ // Update the rolling materialization-sample window before the work that
920
+ // could throw, so the percentiles still move when the downstream local
921
+ // query fails (the materialization step itself ran).
922
+ if (typeof materializationTimeMs === 'number') {
923
+ queryState.materializationSamples.push(materializationTimeMs);
924
+ if (queryState.materializationSamples.length > MATERIALIZATION_SAMPLE_WINDOW) {
925
+ queryState.materializationSamples.shift();
926
+ }
927
+ queryState.lastIngestLatencyMs = materializationTimeMs;
928
+ }
929
+ // Record the SSP internal sub-phase timings (from the WASM binding) so
930
+ // DevTools can attribute ingest cost to store-apply vs circuit-step vs transform.
931
+ if (typeof update.storeApplyMs === 'number')
932
+ this.recordPhase(queryState, 'sspStoreApply', update.storeApplyMs);
933
+ if (typeof update.circuitStepMs === 'number')
934
+ this.recordPhase(queryState, 'sspCircuitStep', update.circuitStepMs);
935
+ if (typeof update.transformMs === 'number')
936
+ this.recordPhase(queryState, 'sspTransform', update.transformMs);
937
+ const percentiles = this.computeMaterializationPercentiles(queryState.materializationSamples);
938
+
939
+ // Fence against bucket switches: this update's `localArray` came from the
940
+ // pre-switch SSP circuit; applying it after a switch would show (and
941
+ // persist) the previous user's ids in the new bucket.
942
+ const epoch = this.local.epoch;
209
943
 
210
- // Update state
211
- const newRecords = records || [];
212
- queryState.config.localArray = localArray;
213
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
214
- id: queryState.config.id,
215
- localArray,
216
- });
944
+ try {
945
+ // Materialize the query's rows. For a windowed (offset) query, re-running
946
+ // the original surql would re-apply `START n` against the shared local DB
947
+ // and skip the window's rows entirely; instead select the SSP's
948
+ // materialized window id-set (`localArray`) directly, re-applying the
949
+ // original ORDER BY for stable display order. Non-offset queries keep the
950
+ // normal re-query path.
951
+ const newRecords = await this.materializeRecords(queryState, localArray);
952
+ if (epoch !== this.local.epoch) return;
953
+ if (!update.synthetic) queryState.config.localArray = localArray;
217
954
 
218
- // Skip notification if records haven't changed
219
955
  const prevJson = JSON.stringify(queryState.records);
220
956
  const newJson = JSON.stringify(newRecords);
221
957
  queryState.records = newRecords;
222
- if (prevJson === newJson) {
958
+ const recordsChanged = prevJson !== newJson;
959
+
960
+ // updateCount counts user-visible updates (matches the prior semantic),
961
+ // while the materialization sample/percentiles already moved above for
962
+ // every observed engine step.
963
+ if (recordsChanged) {
964
+ queryState.updateCount++;
965
+ queryState.lastUpdatedAt = Date.now();
966
+ }
967
+
968
+ // A synthetic re-materialize did not come from an ingest: nothing about
969
+ // the circuit's view changed, so there is nothing to persist for it.
970
+ if (!update.synthetic) {
971
+ await this.local.query(
972
+ surql.seal(
973
+ surql.updateSet('id', [
974
+ 'localArray',
975
+ 'rowCount',
976
+ 'updateCount',
977
+ 'lastIngestLatency',
978
+ 'materializationP55',
979
+ 'materializationP90',
980
+ 'materializationP99',
981
+ ])
982
+ ),
983
+ {
984
+ id: queryState.config.id,
985
+ localArray,
986
+ rowCount: localArray.length,
987
+ updateCount: queryState.updateCount,
988
+ lastIngestLatency: queryState.lastIngestLatencyMs,
989
+ materializationP55: percentiles.p55,
990
+ materializationP90: percentiles.p90,
991
+ materializationP99: percentiles.p99,
992
+ },
993
+ { epoch }
994
+ );
995
+ }
996
+
997
+ if (!recordsChanged) {
223
998
  this.logger.debug(
224
- { queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
999
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
225
1000
  'Query records unchanged, skipping notification'
226
1001
  );
227
1002
  return;
228
1003
  }
229
1004
 
230
- queryState.updateCount++;
231
-
232
1005
  // Notify subscribers
233
1006
  const subscribers = this.subscriptions.get(queryHash);
234
1007
  if (subscribers) {
@@ -240,19 +1013,110 @@ export class DataModule<S extends SchemaStructure> {
240
1013
  this.logger.debug(
241
1014
  {
242
1015
  queryHash,
243
- recordCount: records?.length,
244
- Category: 'spooky-client::DataModule::onStreamUpdate',
1016
+ recordCount: newRecords?.length,
1017
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
245
1018
  },
246
1019
  'Query updated from stream'
247
1020
  );
248
1021
  } catch (err) {
1022
+ if (err instanceof StaleEpochError) {
1023
+ this.logger.debug(
1024
+ { queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
1025
+ 'Dropped stream update from before a bucket switch'
1026
+ );
1027
+ return;
1028
+ }
1029
+ queryState.errorCount++;
249
1030
  this.logger.error(
250
- { err, queryHash, Category: 'spooky-client::DataModule::onStreamUpdate' },
1031
+ { err, queryHash, Category: 'sp00ky-client::DataModule::onStreamUpdate' },
251
1032
  'Failed to fetch records for stream update'
252
1033
  );
1034
+ // Best-effort persist of the bumped errorCount; swallow secondary
1035
+ // failures to avoid masking the original error in logs.
1036
+ try {
1037
+ await this.local.query(surql.seal(surql.updateSet('id', ['errorCount'])), {
1038
+ id: queryState.config.id,
1039
+ errorCount: queryState.errorCount,
1040
+ });
1041
+ } catch (persistErr) {
1042
+ this.logger.warn(
1043
+ {
1044
+ err: persistErr,
1045
+ queryHash,
1046
+ Category: 'sp00ky-client::DataModule::onStreamUpdate',
1047
+ },
1048
+ 'Failed to persist incremented errorCount'
1049
+ );
1050
+ }
253
1051
  }
254
1052
  }
255
1053
 
1054
+ /**
1055
+ * Compute p55/p90/p99 from a rolling window of materialization samples.
1056
+ * Returns nulls for any percentile that has no samples yet so SurrealDB
1057
+ * `option<float>` columns stay NONE rather than 0 before the first ingest.
1058
+ */
1059
+ private computeMaterializationPercentiles(samples: number[]): {
1060
+ p55: number | null;
1061
+ p90: number | null;
1062
+ p99: number | null;
1063
+ } {
1064
+ if (samples.length === 0) {
1065
+ return { p55: null, p90: null, p99: null };
1066
+ }
1067
+ const sorted = [...samples].sort((a, b) => a - b);
1068
+ const pick = (q: number) => {
1069
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length));
1070
+ return sorted[idx]!;
1071
+ };
1072
+ return { p55: pick(0.55), p90: pick(0.9), p99: pick(0.99) };
1073
+ }
1074
+
1075
+ /** Record a per-phase timing sample (ms) on a query's rolling window. */
1076
+ private recordPhase(qs: QueryState, phase: string, ms: number): void {
1077
+ if (!Number.isFinite(ms)) return;
1078
+ const arr = qs.phaseSamples[phase] ?? (qs.phaseSamples[phase] = []);
1079
+ pushSample(arr, ms);
1080
+ qs.phaseLast[phase] = ms;
1081
+ }
1082
+
1083
+ /** Record the remote record-fetch time (ms) for a query. Called by the sync engine. */
1084
+ recordRemoteFetch(hash: string, ms: number): void {
1085
+ const qs = this.activeQueries.get(hash);
1086
+ if (qs) this.recordPhase(qs, 'remoteFetch', ms);
1087
+ }
1088
+
1089
+ /**
1090
+ * Record the frontend reconcile time (ms) for a query. Called from `useQuery`
1091
+ * via `Sp00kyClient.reportFrontendTiming` after it applies an update to its store.
1092
+ */
1093
+ recordFrontendTiming(hash: string, ms: number): void {
1094
+ const qs = this.activeQueries.get(hash);
1095
+ if (qs) this.recordPhase(qs, 'frontend', ms);
1096
+ }
1097
+
1098
+ /**
1099
+ * Build the per-query processing-time breakdown surfaced to the DevTools panel
1100
+ * and the MCP. `ssp` is the WASM-ingest wall time (from `materializationSamples`);
1101
+ * the rest come from the per-phase rolling windows + one-shot registration timings.
1102
+ */
1103
+ phaseTimings(q: QueryState): QueryTimings {
1104
+ const stat = (phase: string) =>
1105
+ phaseStatOf(q.phaseSamples[phase] ?? [], q.phaseLast[phase] ?? null);
1106
+ return {
1107
+ ssp: phaseStatOf(q.materializationSamples, q.lastIngestLatencyMs),
1108
+ sspStoreApply: stat('sspStoreApply'),
1109
+ sspCircuitStep: stat('sspCircuitStep'),
1110
+ sspTransform: stat('sspTransform'),
1111
+ localFetch: stat('localFetch'),
1112
+ remoteFetch: stat('remoteFetch'),
1113
+ frontend: stat('frontend'),
1114
+ registration: q.registrationTimings,
1115
+ updateCount: q.updateCount,
1116
+ errorCount: q.errorCount,
1117
+ };
1118
+ }
1119
+
256
1120
  /**
257
1121
  * Get query state (for sync and devtools)
258
1122
  */
@@ -260,6 +1124,423 @@ export class DataModule<S extends SchemaStructure> {
260
1124
  return this.activeQueries.get(hash);
261
1125
  }
262
1126
 
1127
+ /**
1128
+ * Cold-query guard for instant-hydrate: true when the query exists, hasn't been
1129
+ * hydrated, and has NOT yet fetched its server result (`remoteArray` empty).
1130
+ * We gate on `remoteArray`, not local `records`: a windowed query is often
1131
+ * partially pre-seeded from the circuit (e.g. the dashboard's 5-row preview),
1132
+ * but it still hasn't loaded its own full window from the server — so it should
1133
+ * still hydrate. A warm re-subscribe (remoteArray already populated) is skipped.
1134
+ */
1135
+ isCold(hash: string): boolean {
1136
+ const qs = this.activeQueries.get(hash);
1137
+ return !!qs && !qs.hydrated && (qs.config.remoteArray?.length ?? 0) === 0;
1138
+ }
1139
+
1140
+ /**
1141
+ * Walk a hydrated record's fields and append any EMBEDDED child records to
1142
+ * `batch` (recursing for nested related fields). An embedded child is a
1143
+ * value that is itself a record — a non-null object whose `id` is a
1144
+ * `RecordId` — or an array of such records (one-to-many vs one-to-one). A
1145
+ * bare `RecordId` (a foreign-key reference) or any other value is skipped,
1146
+ * so this never mistakes a FK column for an embedded body. Children are
1147
+ * keyed by their own `record.id.table`, versioned by `_00_rv`, and cleaned
1148
+ * to their table's real columns (which strips the alias/related fields).
1149
+ * `seen` dedupes within the batch.
1150
+ */
1151
+ private collectEmbeddedChildren(
1152
+ record: Record<string, any>,
1153
+ batch: CacheRecord[],
1154
+ seen: Set<string>
1155
+ ): void {
1156
+ const isEmbeddedRecord = (v: unknown): v is RecordWithId =>
1157
+ !!v &&
1158
+ typeof v === 'object' &&
1159
+ !(v instanceof RecordId) &&
1160
+ (v as { id?: unknown }).id instanceof RecordId;
1161
+
1162
+ for (const value of Object.values(record)) {
1163
+ const children = Array.isArray(value)
1164
+ ? value.filter(isEmbeddedRecord)
1165
+ : isEmbeddedRecord(value)
1166
+ ? [value]
1167
+ : [];
1168
+ for (const child of children) {
1169
+ const key = encodeRecordId(child.id);
1170
+ if (seen.has(key)) continue;
1171
+ seen.add(key);
1172
+ // Recurse FIRST so nested grandchildren are captured before `cleanRecord`
1173
+ // strips this child's alias fields.
1174
+ this.collectEmbeddedChildren(child, batch, seen);
1175
+ const table = child.id.table.toString();
1176
+ const tableSchema = this.schema.tables.find((t) => t.name === table);
1177
+ batch.push({
1178
+ table,
1179
+ op: 'CREATE',
1180
+ // Flatten the child too: a nested comment carries its own embedded
1181
+ // `author` object which the schemafull `record<user>` field would
1182
+ // reject. (Its author is captured as a separate row by the recursion
1183
+ // above.)
1184
+ record: this.flattenRelationsForStorage(child, tableSchema),
1185
+ version: (child._00_rv as number) || 1,
1186
+ });
1187
+ }
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Prepare a subquery-bearing row (preload / hydration) for the schemafull
1193
+ * local store: replace an embedded FORWARD-relation object (`author = { id, … }`)
1194
+ * with its RecordId so a `record<…>` field coerces, and DROP reverse-subquery
1195
+ * ARRAYS (`comments = [ … ]`) since their rows are cached separately as their
1196
+ * own bodies. A flat record — as the live `SELECT * FROM $ids` sync returns,
1197
+ * with relations already RecordIds — passes through unchanged.
1198
+ */
1199
+ private flattenRelationsForStorage(
1200
+ record: Record<string, any>,
1201
+ tableSchema?: { columns: Record<string, any> }
1202
+ ): RecordWithId {
1203
+ const isEmbeddedRecord = (v: unknown): boolean =>
1204
+ !!v &&
1205
+ typeof v === 'object' &&
1206
+ !(v instanceof RecordId) &&
1207
+ (v as { id?: unknown }).id instanceof RecordId;
1208
+ const flat: Record<string, any> = {};
1209
+ for (const [k, v] of Object.entries(record)) {
1210
+ if (isEmbeddedRecord(v)) {
1211
+ flat[k] = (v as { id: unknown }).id;
1212
+ } else if (Array.isArray(v) && v.some(isEmbeddedRecord)) {
1213
+ continue;
1214
+ } else {
1215
+ flat[k] = v;
1216
+ }
1217
+ }
1218
+ return (tableSchema ? cleanRecord(tableSchema.columns, flat) : flat) as RecordWithId;
1219
+ }
1220
+
1221
+ /**
1222
+ * Instant-hydrate: ingest rows fetched one-shot from the remote (the query's own
1223
+ * surql run directly) so the query DISPLAYS immediately, while the full realtime
1224
+ * registration proceeds in the background. Ingests with versions (`_00_rv`) so the
1225
+ * later `syncRecords` dedup skips re-pulling unchanged bodies, and seeds
1226
+ * `remoteArray` so windowed queries materialize the correct window (no sparse
1227
+ * local-circuit issue). Runs at most once per query (the `hydrated` flag).
1228
+ */
1229
+ async applyHydration(hash: string, rows: RecordWithId[]): Promise<void> {
1230
+ const queryState = this.activeQueries.get(hash);
1231
+ if (!queryState) return;
1232
+ queryState.hydrated = true; // run-once, even when the remote returns nothing
1233
+ // The registration is enqueued BEFORE the hydrate and can land first; its
1234
+ // `_00_list_ref` set is authoritative and this one-shot must not replace
1235
+ // it. Checked again after the await below for the same reason.
1236
+ if (queryState.serverMembership) return;
1237
+ if (rows.length === 0) return;
1238
+
1239
+ const epoch = this.local.epoch;
1240
+ const tableName = queryState.config.tableName;
1241
+ await this.buildAndSaveCacheBatch(tableName, rows);
1242
+ // Bucket switched while we persisted: these rows were fetched under the
1243
+ // previous auth context — don't let them prime the new bucket's query
1244
+ // state; the rebind's re-registration refills it. (saveBatch's own epoch
1245
+ // fence usually catches this, but the switch can land between it and here.)
1246
+ if (epoch !== this.local.epoch) return;
1247
+ if (queryState.serverMembership) return;
1248
+
1249
+ // Prime remoteArray from the hydrated id+version pairs: `materializeRecords`
1250
+ // renders from it and it feeds the version dedup. Registration later
1251
+ // overwrites it with the authoritative `_00_list_ref`.
1252
+ queryState.config.remoteArray = rows.map(
1253
+ (r) => [encodeRecordId(r.id), (r._00_rv as number) || 1] as [string, number]
1254
+ );
1255
+ // These rows ARE the server's answer to this query, so membership is now
1256
+ // known and rendering can stop falling back to a predicate scan. The durable
1257
+ // `_00_window` mirror is deliberately left to `updateQueryRemoteArray` (the
1258
+ // single write point) — the registration that follows lands there anyway.
1259
+ this.setMembershipKnown(hash, true);
1260
+
1261
+ queryState.records = await this.materializeRecords(queryState);
1262
+ const subscribers = this.subscriptions.get(hash);
1263
+ if (subscribers) {
1264
+ for (const cb of subscribers) cb(queryState.records);
1265
+ }
1266
+ }
1267
+
1268
+ /**
1269
+ * Build the cache batch for a set of one-shot rows and persist it to the
1270
+ * local DB + in-browser SSP. Maps each row to a `CREATE` op on its own table
1271
+ * and extracts EMBEDDED related children (any nesting depth) as their own
1272
+ * records — a `.related()` query returns its children embedded, and a later
1273
+ * correlated re-materialization needs them present as standalone rows.
1274
+ * Shared by `applyHydration` (live registration) and `persistSnapshot`
1275
+ * (preload).
1276
+ */
1277
+ private async buildAndSaveCacheBatch(tableName: string, rows: RecordWithId[]): Promise<void> {
1278
+ const tableSchema = this.schema.tables.find((t) => t.name === tableName);
1279
+ const batch: CacheRecord[] = rows.map((record) => ({
1280
+ table: tableName,
1281
+ op: 'CREATE' as const,
1282
+ // Flatten embedded relations first: a preload/hydration row can carry a
1283
+ // forward relation as a full nested OBJECT (`author = { id, … }`) and a
1284
+ // reverse subquery as an array of objects (`comments = [ … ]`). The
1285
+ // schemafull local field is `record<user>`, which rejects an object
1286
+ // (`Couldn't coerce … found { id: …, username: … }`) and would throw the
1287
+ // WHOLE batch. Store the parent with `author` as its RecordId and drop the
1288
+ // subquery arrays — the children are cached as their own rows below.
1289
+ record: this.flattenRelationsForStorage(record, tableSchema),
1290
+ version: (record._00_rv as number) || 1,
1291
+ }));
1292
+
1293
+ const seen = new Set<string>(rows.map((r) => encodeRecordId(r.id)));
1294
+ for (const record of rows) {
1295
+ this.collectEmbeddedChildren(record, batch, seen);
1296
+ }
1297
+
1298
+ await this.cache.saveBatch(batch);
1299
+ }
1300
+
1301
+ /**
1302
+ * Preload/prewarm: persist one-shot rows (and their embedded related children)
1303
+ * into the local cache WITHOUT registering a query — no `activeQueries` entry,
1304
+ * no `_00_query` view, no TTL heartbeat. The rows live in the local DB as
1305
+ * ordinary bodies (never GC'd on their own) so a later `useQuery` seeds its
1306
+ * first paint from them instantly, then registers a live view to freshen.
1307
+ */
1308
+ async persistSnapshot(tableName: string, rows: RecordWithId[]): Promise<void> {
1309
+ if (rows.length === 0) return;
1310
+ await this.buildAndSaveCacheBatch(tableName, rows);
1311
+ }
1312
+
1313
+ /**
1314
+ * Read the durable preload freshness marker for a query hash, or null if this
1315
+ * query was never preloaded in the current bucket. Co-located with the cached
1316
+ * rows (per-bucket `_00_preload` table) so a bucket switch that clears the
1317
+ * data also clears the marker — a stale marker can't claim "warm" when the
1318
+ * rows are gone. Any read error is treated as cold.
1319
+ */
1320
+ async getPreloadMarker(hash: string): Promise<{ fetchedAt: number; rowCount: number } | null> {
1321
+ try {
1322
+ // Pass a real RecordId: a bare string id hits the SurrealDB engine's
1323
+ // `FROM ONLY $__id` as a plain string, which "selects" the string itself
1324
+ // (a truthy non-row) instead of the record — misread as a warm marker.
1325
+ const row = await this.local.getById('_00_preload', new RecordId('_00_preload', hash));
1326
+ if (!row || typeof row !== 'object') return null;
1327
+ return {
1328
+ fetchedAt: Number((row as any).fetchedAt) || 0,
1329
+ rowCount: Number((row as any).rowCount) || 0,
1330
+ };
1331
+ } catch {
1332
+ return null;
1333
+ }
1334
+ }
1335
+
1336
+ /** Stamp the preload freshness marker after a successful snapshot fetch. */
1337
+ async writePreloadMarker(hash: string, rowCount: number): Promise<void> {
1338
+ // RecordId, not a bare string: the SurrealDB engine binds the id verbatim,
1339
+ // and `UPSERT <string>` is an InternalError — the marker silently never
1340
+ // landed (the write is awaited inside preload's best-effort catch).
1341
+ await this.local.upsert(
1342
+ '_00_preload',
1343
+ new RecordId('_00_preload', hash),
1344
+ { fetchedAt: Date.now(), rowCount },
1345
+ 'replace'
1346
+ );
1347
+ }
1348
+
1349
+ // ---- Durable membership (`_00_window`) ---------------------------------
1350
+ //
1351
+ // A query's authoritative membership is the id-set the server put in
1352
+ // `_00_list_ref` — `config.remoteArray`. It is persisted on the `_00_query`
1353
+ // row, but that row's id is salted with `session::id()`, which is new on every
1354
+ // page load (and `''` offline), and `doSwitchBucket` wipes the table outright.
1355
+ // So membership never survived a reload: the first paint fell back to a
1356
+ // predicate scan over whatever bodies were still cached, which re-included
1357
+ // rows the server had already dropped from the window.
1358
+ //
1359
+ // `_00_window` fixes that: same data, keyed by a session-independent hash, in
1360
+ // a table nothing wipes. Mirrors the durable `_00_preload` marker above.
1361
+ //
1362
+ // Row shape: `{ ids, confirmed, updatedAt }`. `confirmed` is the one bit that
1363
+ // lets an EMPTY row be trusted on the next boot (see `getWindowMembership`).
1364
+
1365
+ /**
1366
+ * Read the durable membership row, or `null` if this query has never had
1367
+ * authoritative membership on this device. Any read error is treated as
1368
+ * "unknown" so a broken row degrades to the predicate scan rather than
1369
+ * rendering an empty list.
1370
+ *
1371
+ * `confirmed` is true only for rows written after the server itself vouched
1372
+ * for the set (a non-empty id-set, or an empty one it reported a row count of
1373
+ * zero for, or an empty one that followed a non-empty one in the same
1374
+ * session). Rows written before the marker existed, including the `[]` rows a
1375
+ * pre-`ea56f50e` client mirrored from an unflushed read, read as unconfirmed.
1376
+ */
1377
+ async getWindowMembership(key: string): Promise<DurableMembership | null> {
1378
+ try {
1379
+ const row = await this.local.getById('_00_window', new RecordId('_00_window', key));
1380
+ if (!row || typeof row !== 'object') return null;
1381
+ const ids = (row as any).ids;
1382
+ if (!Array.isArray(ids)) return null;
1383
+ return { ids: ids as RecordVersionArray, confirmed: (row as any).confirmed === true };
1384
+ } catch {
1385
+ return null;
1386
+ }
1387
+ }
1388
+
1389
+ /**
1390
+ * Persist the durable membership row. Best-effort: callers must not fail a
1391
+ * sync round because the mirror write failed.
1392
+ *
1393
+ * `confirmed` says whether a cold start may trust this row even when it is
1394
+ * empty. A confirmed empty is a real answer ("the server says this query has
1395
+ * no rows") and stays empty across a reload; an unconfirmed empty is the
1396
+ * retry budget's guess and falls back to the predicate scan on the next boot,
1397
+ * exactly as every empty row did before the marker existed.
1398
+ */
1399
+ async writeWindowMembership(
1400
+ key: string,
1401
+ ids: RecordVersionArray,
1402
+ confirmed: boolean
1403
+ ): Promise<void> {
1404
+ try {
1405
+ await this.local.upsert(
1406
+ '_00_window',
1407
+ new RecordId('_00_window', key),
1408
+ { ids, confirmed, updatedAt: Date.now() },
1409
+ 'replace'
1410
+ );
1411
+ } catch (err) {
1412
+ this.logger.debug(
1413
+ { err, key, Category: 'sp00ky-client::DataModule::writeWindowMembership' },
1414
+ 'Failed to persist window membership; it will be re-derived on next register'
1415
+ );
1416
+ }
1417
+ }
1418
+
1419
+ /**
1420
+ * Record ids with a mutation still in the outbox, split by direction.
1421
+ *
1422
+ * Both halves feed {@link materializeRecords}: `writes` keeps optimistic
1423
+ * creates/updates visible before the server has acknowledged them, and
1424
+ * `deletes` suppresses rows the server still lists because our DELETE hasn't
1425
+ * been processed yet. Reading `_00_pending_mutations` (rather than tracking
1426
+ * ids in memory) is what makes both survive a reload.
1427
+ *
1428
+ * On failure returns empty sets: membership alone then decides, which can
1429
+ * briefly hide an optimistic write but never resurrects a deleted row.
1430
+ */
1431
+ async getPendingRecordIds(): Promise<{ writes: Set<string>; deletes: Set<string> }> {
1432
+ const now = Date.now();
1433
+ const cached = this.pendingIds;
1434
+ if (cached && now - this.pendingIdsAt < DataModule.PENDING_IDS_TTL_MS) {
1435
+ // COPIES: `buildRenderIds` merges the settled-write ids into these sets,
1436
+ // and handing out the cached instances would let it grow them permanently.
1437
+ return { writes: new Set(cached.writes), deletes: new Set(cached.deletes) };
1438
+ }
1439
+ // Single-flight: one ingest fans out to many queries materializing at once,
1440
+ // and without this they all queue their own identical read behind each other.
1441
+ // Bounded re-read: a result from a generation older than the one current
1442
+ // by the time it lands was taken before some outbox row existed, so it is
1443
+ // re-issued rather than returned. Bounded, because a write on every tick
1444
+ // must not spin this forever - the last read is returned then.
1445
+ let fresh: { writes: Set<string>; deletes: Set<string> } | null = null;
1446
+ for (let attempt = 0; attempt < DataModule.PENDING_IDS_MAX_REREADS; attempt++) {
1447
+ const gen = this.pendingIdsGen;
1448
+ if (!this.pendingIdsInflight) {
1449
+ const read = this.readPendingRecordIds(gen).finally(() => {
1450
+ // Only clear the slot this read still owns; an invalidation in the
1451
+ // meantime has replaced it (with null) for a newer read to fill.
1452
+ if (this.pendingIdsInflight === read) this.pendingIdsInflight = null;
1453
+ });
1454
+ this.pendingIdsInflight = read;
1455
+ }
1456
+ fresh = await this.pendingIdsInflight;
1457
+ if (gen === this.pendingIdsGen) break;
1458
+ }
1459
+ // oxlint-disable-next-line no-non-null-assertion -- the loop runs at least once
1460
+ return { writes: new Set(fresh!.writes), deletes: new Set(fresh!.deletes) };
1461
+ }
1462
+
1463
+ /** The uncached read. Also the reload path after an invalidation, so the ids
1464
+ * still survive a reload exactly as before. `gen` is the generation the read
1465
+ * was issued under; the result is cached only if it is still current. */
1466
+ private async readPendingRecordIds(gen: number): Promise<{
1467
+ writes: Set<string>;
1468
+ deletes: Set<string>;
1469
+ }> {
1470
+ const writes = new Set<string>();
1471
+ const deletes = new Set<string>();
1472
+ try {
1473
+ const [rows] = await this.local.query<
1474
+ [{ recordId: RecordId<string>; mutationType: string }[]]
1475
+ >('SELECT recordId, mutationType FROM _00_pending_mutations');
1476
+ for (const row of rows ?? []) {
1477
+ if (!row?.recordId) continue;
1478
+ const id = encodeRecordId(row.recordId);
1479
+ if (row.mutationType === 'delete') deletes.add(id);
1480
+ else writes.add(id);
1481
+ }
1482
+ } catch (err) {
1483
+ this.logger.warn(
1484
+ { err, Category: 'sp00ky-client::DataModule::getPendingRecordIds' },
1485
+ 'Failed to read pending mutations; optimistic writes may be briefly hidden'
1486
+ );
1487
+ // Do NOT cache a failed read: the empty sets are a fallback for this call,
1488
+ // not a statement that the outbox is empty.
1489
+ return { writes, deletes };
1490
+ }
1491
+ if (gen === this.pendingIdsGen) {
1492
+ this.pendingIds = { writes, deletes };
1493
+ this.pendingIdsAt = Date.now();
1494
+ }
1495
+ return { writes, deletes };
1496
+ }
1497
+
1498
+ /** True while ≥1 live subscriber is watching this query (refcount guard). */
1499
+ hasSubscribers(hash: string): boolean {
1500
+ return (this.subscriptions.get(hash)?.size ?? 0) > 0;
1501
+ }
1502
+
1503
+ /**
1504
+ * Opt-in eager teardown for a query whose LAST subscriber just left — used by
1505
+ * viewport-windowed lists to cancel off-screen windows instead of leaving
1506
+ * their remote views to expire on the TTL sweep. No-op while any subscriber
1507
+ * remains (refcount). Only enqueues the remote cleanup here; the local WASM
1508
+ * view + in-memory state are freed in {@link finalizeDeregister} after the
1509
+ * remote delete completes, so a re-subscribe in between aborts/heals it.
1510
+ *
1511
+ * NOTE: most queries should NOT use this — the default keep-alive on
1512
+ * unsubscribe avoids re-registration churn on navigation.
1513
+ */
1514
+ deregisterQuery(hash: string): void {
1515
+ if (this.hasSubscribers(hash)) return;
1516
+ if (!this.activeQueries.has(hash)) return;
1517
+ this.onDeregister?.(hash);
1518
+ }
1519
+
1520
+ /**
1521
+ * Final local teardown after the remote `_00_query` row was deleted: free the
1522
+ * WASM view, heartbeat timer, debounce timer, and in-memory state. Caller
1523
+ * (`cleanupQuery`) guarantees no subscriber remains.
1524
+ */
1525
+ finalizeDeregister(hash: string): void {
1526
+ const qs = this.activeQueries.get(hash);
1527
+ if (qs?.ttlTimer) {
1528
+ clearTimeout(qs.ttlTimer);
1529
+ qs.ttlTimer = null;
1530
+ }
1531
+ const debounce = this.debounceTimers.get(hash);
1532
+ if (debounce) {
1533
+ clearTimeout(debounce);
1534
+ this.debounceTimers.delete(hash);
1535
+ }
1536
+ this.pendingStreamUpdates.delete(hash);
1537
+ this.fetchDepth.delete(hash);
1538
+ this.cache.unregisterQuery(hash);
1539
+ this.activeQueries.delete(hash);
1540
+ this.subscriptions.delete(hash);
1541
+ this.authoritySubscriptions.delete(hash);
1542
+ }
1543
+
263
1544
  /**
264
1545
  * Get query state by id (for sync and devtools)
265
1546
  */
@@ -274,36 +1555,270 @@ export class DataModule<S extends SchemaStructure> {
274
1555
  return Array.from(this.activeQueries.values());
275
1556
  }
276
1557
 
1558
+ getActiveQueryHashes(): QueryHash[] {
1559
+ return Array.from(this.activeQueries.keys());
1560
+ }
1561
+
277
1562
  async updateQueryLocalArray(id: string, localArray: RecordVersionArray): Promise<void> {
278
1563
  const queryState = this.activeQueries.get(id);
279
1564
  if (!queryState) {
280
1565
  this.logger.warn(
281
- { id, Category: 'spooky-client::DataModule::updateQueryLocalArray' },
1566
+ { id, Category: 'sp00ky-client::DataModule::updateQueryLocalArray' },
282
1567
  'Query to update local array not found'
283
1568
  );
284
1569
  return;
285
1570
  }
1571
+ const epoch = this.local.epoch;
286
1572
  queryState.config.localArray = localArray;
287
- await this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
288
- id: queryState.config.id,
289
- localArray,
290
- });
1573
+ try {
1574
+ await this.local.query(
1575
+ surql.seal(surql.updateSet('id', ['localArray'])),
1576
+ {
1577
+ id: queryState.config.id,
1578
+ localArray,
1579
+ },
1580
+ { epoch }
1581
+ );
1582
+ } catch (err) {
1583
+ if (err instanceof StaleEpochError) return;
1584
+ throw err;
1585
+ }
291
1586
  }
292
1587
 
293
- async updateQueryRemoteArray(hash: string, remoteArray: RecordVersionArray): Promise<void> {
1588
+ async updateQueryRemoteArray(
1589
+ hash: string,
1590
+ remoteArray: RecordVersionArray,
1591
+ opts?: {
1592
+ /** The `_00_query` row as read in the same round trip as the edges. */
1593
+ meta?: ServerViewMeta;
1594
+ /**
1595
+ * The caller verified upstream that the ids missing from `remoteArray`
1596
+ * no longer exist (see `Sp00kySync` LIVE removal handling); an empty set
1597
+ * is then a real answer even without a readable row.
1598
+ */
1599
+ verifiedRemoval?: boolean;
1600
+ }
1601
+ ): Promise<MembershipOutcome> {
294
1602
  const queryState = this.getQueryByHash(hash);
295
1603
  if (!queryState) {
296
1604
  this.logger.warn(
297
- { hash, Category: 'spooky-client::DataModule::updateQueryRemoteArray' },
1605
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
298
1606
  'Query to update remote array not found'
299
1607
  );
300
- return;
1608
+ return 'ignored';
301
1609
  }
1610
+ const meta = opts?.meta;
1611
+ if (meta) queryState.serverState = meta.present ? meta.state : null;
1612
+ const held = queryState.config.remoteArray ?? [];
1613
+
1614
+ // A NON-EMPTY set is always the server's answer. An EMPTY set is only
1615
+ // believed when the server stands behind it, because "no edges" has four
1616
+ // causes and only one of them is "no rows":
1617
+ //
1618
+ // - the `_00_query` row is gone (`meta.present === false`): a TTL sweep,
1619
+ // an SSP reset or a scheduler wipe took the view. The client keeps what
1620
+ // it renders and asks for a re-registration ("view lost"). Believing
1621
+ // the empty here is what blanked lists after every sweep and, via the
1622
+ // durable `_00_window` mirror, kept them blank across reloads.
1623
+ // - `state === 'materializing'`: the SSP has computed the set and its
1624
+ // edges are in flight (the flusher window). Not an answer yet.
1625
+ // - `state === 'ready'` with `rowCount > 0` (or unreadable): the row and
1626
+ // the edges disagree (metrics lag, a stranded view). Not trusted.
1627
+ // - `state === 'ready'` with `rowCount === 0`: a real empty result. The
1628
+ // SSP flips `ready` inside the transaction that writes the edges, so
1629
+ // this cannot be observed mid-publish.
1630
+ //
1631
+ // A server that predates `state` (`state === null`) can only be judged on
1632
+ // `rowCount`: `0` is empty, anything else is the flush-window ambiguity and
1633
+ // is left alone. There is deliberately NO retry budget that eventually
1634
+ // accepts an unexplained empty: the register lifecycle, the poll and the
1635
+ // heartbeat all retry, and a server that cannot explain an empty must not
1636
+ // be able to erase a device's data.
1637
+ if (remoteArray.length === 0 && !opts?.verifiedRemoval) {
1638
+ if (!meta || !meta.present) {
1639
+ if (held.length > 0 || queryState.config.membershipKnown) {
1640
+ if (!queryState.viewLost) {
1641
+ queryState.viewLost = true;
1642
+ this.logger.warn(
1643
+ { hash, held: held.length, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
1644
+ 'Server has no _00_query row for a query we hold membership for; keeping local state and re-registering'
1645
+ );
1646
+ this.onViewLost?.(hash);
1647
+ }
1648
+ return 'view-lost';
1649
+ }
1650
+ this.logger.debug(
1651
+ { hash, Category: 'sp00ky-client::DataModule::updateQueryRemoteArray' },
1652
+ 'Ignoring empty membership: the _00_query row is not readable yet'
1653
+ );
1654
+ return 'ignored';
1655
+ }
1656
+ const knownEmpty = meta.rowCount === 0 && meta.state !== 'materializing';
1657
+ if (!knownEmpty) {
1658
+ this.logger.debug(
1659
+ {
1660
+ hash,
1661
+ state: meta.state,
1662
+ rowCount: meta.rowCount,
1663
+ Category: 'sp00ky-client::DataModule::updateQueryRemoteArray',
1664
+ },
1665
+ 'Ignoring empty membership: the server has not confirmed this query empty'
1666
+ );
1667
+ return 'ignored';
1668
+ }
1669
+ }
1670
+ if (meta?.present) queryState.viewLost = false;
1671
+
1672
+ const epoch = this.local.epoch;
302
1673
  queryState.config.remoteArray = remoteArray;
303
- await this.local.query(surql.seal(surql.updateSet('id', ['remoteArray'])), {
304
- id: queryState.config.id,
305
- remoteArray,
306
- });
1674
+ // The single point where authoritative membership arrives (registration,
1675
+ // the `_00_list_ref` poll and verified LIVE removals all land here), so it
1676
+ // is where "we now know the membership" is latched and where the durable
1677
+ // mirror is written. Every set that reaches this line is server-backed, so
1678
+ // the mirror is always `confirmed`: a reload may trust it even when empty.
1679
+ this.setMembershipKnown(hash, true);
1680
+ queryState.serverMembership = true;
1681
+ if (queryState.config.membershipKey) {
1682
+ await this.writeWindowMembership(queryState.config.membershipKey, remoteArray, true);
1683
+ }
1684
+ try {
1685
+ await this.local.query(
1686
+ surql.seal(surql.updateSet('id', ['remoteArray'])),
1687
+ {
1688
+ id: queryState.config.id,
1689
+ remoteArray,
1690
+ },
1691
+ { epoch }
1692
+ );
1693
+ } catch (err) {
1694
+ if (err instanceof StaleEpochError) return 'applied';
1695
+ throw err;
1696
+ }
1697
+ return 'applied';
1698
+ }
1699
+
1700
+ /**
1701
+ * Cancel every armed timer ahead of a local-bucket switch: stream-update
1702
+ * debounce timers (their pending updates carry the OLD bucket's id-sets) and
1703
+ * per-query TTL heartbeats (they'd refresh the previous user's remote
1704
+ * `_00_query` rows under the new session). The rebind re-arms heartbeats.
1705
+ */
1706
+ quiesce(): void {
1707
+ for (const timer of this.debounceTimers.values()) {
1708
+ clearTimeout(timer);
1709
+ }
1710
+ this.debounceTimers.clear();
1711
+ this.pendingStreamUpdates.clear();
1712
+ const wasFetching = this.fetchDepth.size > 0;
1713
+ this.fetchDepth.clear();
1714
+ if (wasFetching) this.notifyActivity();
1715
+ for (const queryState of this.activeQueries.values()) {
1716
+ if (queryState.ttlTimer) {
1717
+ clearTimeout(queryState.ttlTimer);
1718
+ queryState.ttlTimer = null;
1719
+ }
1720
+ }
1721
+ }
1722
+
1723
+ /**
1724
+ * Re-home every active query in a freshly-opened bucket, KEEPING its hash —
1725
+ * `useQuery` subscriptions are keyed by hash and don't re-register on auth
1726
+ * changes, so the hooks must stay attached. Per query:
1727
+ * 1. reset the sync arrays + hydration flag and drop the previous user's
1728
+ * records, notifying subscribers with the new-bucket materialization
1729
+ * (usually empty) so their rows leave the UI immediately;
1730
+ * 2. recreate the `_00_query` row in the new bucket;
1731
+ * 3. re-register the SSP view on the (fresh, post-reset) processor — this
1732
+ * also rebinds the view to the NEW `$auth` context;
1733
+ * 4. restart the TTL heartbeat.
1734
+ * Returns the hashes so the caller can enqueue remote re-registration, which
1735
+ * refills records from the server via the normal register→sync→notify path.
1736
+ */
1737
+ async rebindAfterBucketSwitch(): Promise<QueryHash[]> {
1738
+ const hashes: QueryHash[] = [];
1739
+ for (const [hash, queryState] of this.activeQueries.entries()) {
1740
+ const config = queryState.config;
1741
+ config.localArray = [];
1742
+ config.remoteArray = [];
1743
+ config.subqueryRemoteArray = undefined;
1744
+ // Membership belongs to the bucket we just left. Re-seed from the NEW
1745
+ // bucket's durable row if it has one (a returning user), otherwise mark it
1746
+ // unknown so the first paint falls back to a scan instead of rendering an
1747
+ // empty list until the server answers.
1748
+ // Via the single writer so `subscribeAuthority` observers see the reset
1749
+ // BEFORE the `[]` records notify below, and the re-latch after it.
1750
+ this.setMembershipKnown(hash, false);
1751
+ queryState.viewLost = false;
1752
+ queryState.serverState = null;
1753
+ if (config.membershipKey) {
1754
+ const durable = await this.getWindowMembership(config.membershipKey);
1755
+ // Same rule as the cold-start read: a non-empty row, or an empty one
1756
+ // the server confirmed, is membership; an unmarked empty row is not.
1757
+ if (durable && (durable.ids.length > 0 || durable.confirmed)) {
1758
+ config.remoteArray = durable.ids;
1759
+ this.setMembershipKnown(hash, true);
1760
+ }
1761
+ }
1762
+ queryState.hydrated = false;
1763
+ queryState.serverMembership = false;
1764
+ queryState.syncNotified = false;
1765
+ queryState.records = [];
1766
+ // Via setQueryStatus (not a bare assignment) so status observers see the
1767
+ // flip back to a loading state.
1768
+ this.setQueryStatus(hash, 'fetching');
1769
+
1770
+ try {
1771
+ await withRetry(this.logger, () =>
1772
+ this.local.query<[QueryConfigRecord]>(surql.seal(surql.create('id', 'data')), {
1773
+ id: config.id,
1774
+ data: {
1775
+ surql: config.surql,
1776
+ params: config.params,
1777
+ localArray: [],
1778
+ remoteArray: [],
1779
+ lastActiveAt: new Date(),
1780
+ createdAt: new Date(),
1781
+ ttl: config.ttl,
1782
+ tableName: config.tableName,
1783
+ updateCount: queryState.updateCount,
1784
+ rowCount: 0,
1785
+ errorCount: queryState.errorCount,
1786
+ },
1787
+ })
1788
+ );
1789
+
1790
+ const { localArray } = this.cache.registerQuery({
1791
+ queryHash: hash,
1792
+ surql: config.surql,
1793
+ params: config.params,
1794
+ ttl: new Duration(config.ttl),
1795
+ lastActiveAt: new Date(),
1796
+ });
1797
+ config.localArray = localArray;
1798
+ await this.local.query(surql.seal(surql.updateSet('id', ['localArray', 'rowCount'])), {
1799
+ id: config.id,
1800
+ localArray,
1801
+ rowCount: localArray.length,
1802
+ });
1803
+ } catch (err) {
1804
+ this.logger.error(
1805
+ { err, hash, Category: 'sp00ky-client::DataModule::rebindAfterBucketSwitch' },
1806
+ 'Failed to rebind query after bucket switch; remote re-registration will retry'
1807
+ );
1808
+ }
1809
+
1810
+ // Notify AFTER the SSP re-registration so a subscriber that re-reads
1811
+ // synchronously sees consistent (empty) state.
1812
+ const subscribers = this.subscriptions.get(hash);
1813
+ if (subscribers) {
1814
+ for (const callback of subscribers) {
1815
+ callback(queryState.records);
1816
+ }
1817
+ }
1818
+ this.startTTLHeartbeat(queryState, hash);
1819
+ hashes.push(hash);
1820
+ }
1821
+ return hashes;
307
1822
  }
308
1823
 
309
1824
  /**
@@ -313,20 +1828,29 @@ export class DataModule<S extends SchemaStructure> {
313
1828
  async notifyQuerySynced(queryHash: string): Promise<void> {
314
1829
  const queryState = this.activeQueries.get(queryHash);
315
1830
  if (!queryState) return;
316
-
317
- // Re-query local DB for latest data
318
- const [records] = await this.local.query<[Record<string, any>[]]>(
319
- queryState.config.surql,
320
- queryState.config.params
321
- );
322
- const newRecords = records || [];
1831
+ const epoch = this.local.epoch;
1832
+
1833
+ // Re-query local DB for latest data (windowed queries materialize from the
1834
+ // list_ref window so they resolve even if the in-browser SSP never emits —
1835
+ // it can't compute a high offset whose preceding rows aren't resident).
1836
+ const newRecords = await this.materializeRecords(queryState);
1837
+ // Bucket switched while we materialized: these rows mix old-bucket reads
1838
+ // with new-bucket state — drop them; the rebind/re-registration re-emits.
1839
+ if (epoch !== this.local.epoch) return;
323
1840
  const changed = JSON.stringify(queryState.records) !== JSON.stringify(newRecords);
324
1841
  queryState.records = newRecords;
325
1842
 
326
- // Notify if data changed OR if this is the first sync (updateCount === 0)
327
- // The latter handles "query truly has no results" so UI can stop loading
328
- if (changed || queryState.updateCount === 0) {
1843
+ // Notify if data changed OR if this registration lifetime hasn't emitted a
1844
+ // post-sync notification yet. The latter handles "query truly has no
1845
+ // results" so the UI can stop loading — gated on the in-memory
1846
+ // `syncNotified` flag rather than `updateCount === 0`, because updateCount
1847
+ // is PERSISTED across deregister/re-register: a re-registered empty window
1848
+ // (updateCount > 0, records unchanged) would otherwise never emit and its
1849
+ // subscribers would show a loading state forever.
1850
+ if (changed || !queryState.syncNotified) {
1851
+ queryState.syncNotified = true;
329
1852
  queryState.updateCount++;
1853
+ queryState.lastUpdatedAt = Date.now();
330
1854
  const subscribers = this.subscriptions.get(queryHash);
331
1855
  if (subscribers) {
332
1856
  for (const callback of subscribers) {
@@ -344,6 +1868,24 @@ export class DataModule<S extends SchemaStructure> {
344
1868
  data: RoutePayload<S, B, R>,
345
1869
  options?: RunOptions
346
1870
  ): Promise<void> {
1871
+ const { tableName, record } = this.buildJobRecord(backend, path, data, options);
1872
+ const recordId = `${tableName}:${generateId()}`;
1873
+ await this.create(recordId, record);
1874
+ }
1875
+
1876
+ /**
1877
+ * Build the outbox job record + resolve its table for a backend route.
1878
+ *
1879
+ * Every job is a single execution. Recurring work is declared server-side
1880
+ * (`schedules:` in sp00ky.yml) and the scheduler creates a fresh row per cycle,
1881
+ * so nothing here needs to know about schedules.
1882
+ */
1883
+ private buildJobRecord<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
1884
+ backend: B,
1885
+ path: R,
1886
+ data: RoutePayload<S, B, R>,
1887
+ options?: RunOptions
1888
+ ): { tableName: string; record: Record<string, unknown> } {
347
1889
  const route = this.schema.backends?.[backend]?.routes?.[path];
348
1890
  if (!route) {
349
1891
  throw new Error(`Route ${backend}.${path} not found`);
@@ -366,16 +1908,28 @@ export class DataModule<S extends SchemaStructure> {
366
1908
  const record: Record<string, unknown> = {
367
1909
  path,
368
1910
  payload: JSON.stringify(payload),
1911
+ // Set explicitly: the schema's DEFAULT ALWAYS "pending" is server-side
1912
+ // only. An optimistic local create without it surfaces with status
1913
+ // undefined, so in-flight indicators keyed on pending/processing stay
1914
+ // off until the first server echo (seconds on a delayed job).
1915
+ status: 'pending',
369
1916
  max_retries: options?.max_retries ?? 3,
370
1917
  retry_strategy: options?.retry_strategy ?? 'linear',
371
1918
  };
372
1919
 
1920
+ if (options?.timeout != null) {
1921
+ record.timeout = options.timeout;
1922
+ }
1923
+
1924
+ if (options?.delay != null) {
1925
+ record.delay = options.delay;
1926
+ }
1927
+
373
1928
  if (options?.assignedTo) {
374
1929
  record.assigned_to = options.assignedTo;
375
1930
  }
376
1931
 
377
- const recordId = `${tableName}:${generateId()}`;
378
- await this.create(recordId, record);
1932
+ return { tableName, record };
379
1933
  }
380
1934
 
381
1935
  // ==================== MUTATION MANAGEMENT ====================
@@ -392,7 +1946,7 @@ export class DataModule<S extends SchemaStructure> {
392
1946
 
393
1947
  const rid = parseRecordIdString(id);
394
1948
  const params = parseParams(tableSchema.columns, data);
395
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
1949
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
396
1950
 
397
1951
  const dataKeys = Object.keys(params).map((key) => ({ key, variable: `data_${key}` }));
398
1952
  const prefixedParams = Object.fromEntries(
@@ -410,22 +1964,46 @@ export class DataModule<S extends SchemaStructure> {
410
1964
  this.local.execute(query, {
411
1965
  id: rid,
412
1966
  mid: mutationId,
1967
+ // The record itself is written with per-key `data_<field>` vars
1968
+ // (`createSet`), but the OUTBOX row needs the whole payload in one
1969
+ // `data` field so the mutation can be replayed later. Without this the
1970
+ // row is written with `data` unbound: the create becomes unsendable and
1971
+ // blocks the queue behind it.
1972
+ data: params,
413
1973
  ...prefixedParams,
414
1974
  })
415
1975
  );
416
1976
 
1977
+ // The local tx has committed: the row and its outbox entry exist. The
1978
+ // cached pending-id sets no longer describe the outbox, so drop them HERE,
1979
+ // before the ingest below fans out to every view's materialization (which
1980
+ // is exactly when they are read).
1981
+ this.invalidatePendingIds();
1982
+
417
1983
  const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
418
1984
 
419
- // Save to cache (which handles DBSP ingestion)
420
- await this.cache.save(
421
- {
422
- table: tableName,
423
- op: 'CREATE',
424
- record: parsedRecord,
425
- version: 1,
426
- },
427
- true
428
- );
1985
+ // Save to cache (which handles DBSP ingestion). Best-effort, like
1986
+ // `delete()`: the local write is durable and the outbox row exists, so a
1987
+ // throw from the in-browser ingest must not abort the rest of this method -
1988
+ // it used to, and then the mutation was never handed to sync, the row sat
1989
+ // in the outbox until the next leader promotion re-scanned it, and the
1990
+ // caller's promise rejected after the write had already happened.
1991
+ try {
1992
+ await this.cache.save(
1993
+ {
1994
+ table: tableName,
1995
+ op: 'CREATE',
1996
+ record: parsedRecord,
1997
+ version: 1,
1998
+ },
1999
+ true
2000
+ );
2001
+ } catch (err) {
2002
+ this.logger.error(
2003
+ { err, id, Category: 'sp00ky-client::DataModule::create' },
2004
+ 'SSP create-ingest failed; the row is written and queued, but views only see it once membership arrives'
2005
+ );
2006
+ }
429
2007
 
430
2008
  // Emit mutation event for sync
431
2009
  const mutationEvent: CreateEvent = {
@@ -441,7 +2019,7 @@ export class DataModule<S extends SchemaStructure> {
441
2019
  callback([mutationEvent]);
442
2020
  }
443
2021
 
444
- this.logger.debug({ id, Category: 'spooky-client::DataModule::create' }, 'Record created');
2022
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::create' }, 'Record created');
445
2023
 
446
2024
  return target;
447
2025
  }
@@ -463,7 +2041,10 @@ export class DataModule<S extends SchemaStructure> {
463
2041
 
464
2042
  const rid = parseRecordIdString(id);
465
2043
  const params = parseParams(tableSchema.columns, data);
466
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
2044
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
2045
+
2046
+ // Note: CRDT state is pushed directly to the _00_crdt table by CrdtField.pushToRemote(),
2047
+ // NOT through the record update pipeline. This keeps the record data clean.
467
2048
 
468
2049
  // Capture current record state before mutation for rollback support
469
2050
  const [beforeRecord] = await withRetry(this.logger, () =>
@@ -472,7 +2053,7 @@ export class DataModule<S extends SchemaStructure> {
472
2053
 
473
2054
  const query = surql.seal<{ target: T }>(
474
2055
  surql.tx([
475
- surql.updateSet('id', [{ statement: 'spooky_rv += 1' }]),
2056
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
476
2057
  surql.let('updated', surql.updateMerge('id', 'data')),
477
2058
  surql.createMutation('update', 'mid', 'id', 'data'),
478
2059
  surql.returnObject([{ key: 'target', variable: 'updated' }]),
@@ -496,23 +2077,33 @@ export class DataModule<S extends SchemaStructure> {
496
2077
  updatedFields[key] = (target as Record<string, any>)[key];
497
2078
  }
498
2079
  }
499
- if ('spooky_rv' in (target as Record<string, any>)) {
500
- updatedFields.spooky_rv = (target as Record<string, any>).spooky_rv;
2080
+ if ('_00_rv' in (target as Record<string, any>)) {
2081
+ updatedFields._00_rv = (target as Record<string, any>)._00_rv;
501
2082
  }
502
2083
  this.replaceRecordInQueries(updatedFields);
503
2084
 
2085
+ // Committed: see create() for why this precedes the ingest.
2086
+ this.invalidatePendingIds();
2087
+
504
2088
  const parsedRecord = parseParams(tableSchema.columns, target) as RecordWithId;
505
2089
 
506
- // Save to cache
507
- await this.cache.save(
508
- {
509
- table: table,
510
- op: 'UPDATE',
511
- record: parsedRecord,
512
- version: target.spooky_rv as number,
513
- },
514
- true
515
- );
2090
+ // Save to cache. Best-effort for the same reason as in create().
2091
+ try {
2092
+ await this.cache.save(
2093
+ {
2094
+ table: table,
2095
+ op: 'UPDATE',
2096
+ record: parsedRecord,
2097
+ version: target._00_rv as number,
2098
+ },
2099
+ true
2100
+ );
2101
+ } catch (err) {
2102
+ this.logger.error(
2103
+ { err, id, Category: 'sp00ky-client::DataModule::update' },
2104
+ 'SSP update-ingest failed; the row is written and queued'
2105
+ );
2106
+ }
516
2107
 
517
2108
  const pushEventOptions = parseUpdateOptions(id, data, options);
518
2109
 
@@ -531,7 +2122,7 @@ export class DataModule<S extends SchemaStructure> {
531
2122
  callback([mutationEvent]);
532
2123
  }
533
2124
 
534
- this.logger.debug({ id, Category: 'spooky-client::DataModule::update' }, 'Record updated');
2125
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::update' }, 'Record updated');
535
2126
 
536
2127
  return target;
537
2128
  }
@@ -547,14 +2138,45 @@ export class DataModule<S extends SchemaStructure> {
547
2138
  }
548
2139
 
549
2140
  const rid = parseRecordIdString(id);
550
- const mutationId = parseRecordIdString(`_spooky_pending_mutations:${Date.now()}`);
2141
+ const mutationId = parseRecordIdString(mintMutationId(this.tabId));
2142
+
2143
+ // Fetch the record before deleting so DBSP can match it against query predicates
2144
+ const [beforeRecords] = await this.local.query<[Record<string, any>[]]>(
2145
+ 'SELECT * FROM ONLY $id',
2146
+ { id: rid }
2147
+ );
2148
+ const beforeRecord = beforeRecords ?? {};
551
2149
 
552
2150
  const query = surql.seal<void>(
553
2151
  surql.tx([surql.delete('id'), surql.createMutation('delete', 'mid', 'id')])
554
2152
  );
555
2153
 
556
2154
  await withRetry(this.logger, () => this.local.execute(query, { id: rid, mid: mutationId }));
557
- await this.cache.delete(table, id, true);
2155
+
2156
+ // Committed: the outbox row exists, drop the cached pending-id sets before
2157
+ // the re-materialize below reads them (see create()).
2158
+ this.invalidatePendingIds();
2159
+
2160
+ // The local DELETE has now committed. Everything below must reflect that in
2161
+ // active live queries — so the deleted row disappears optimistically without
2162
+ // a reload — even if the optimistic SSP-view ingest below fails. Previously a
2163
+ // throw from `cache.delete` (the WASM ingest) aborted `delete()` after the
2164
+ // commit, so the manual notify loop never ran and the row lingered on screen
2165
+ // until reload. Ingesting the delete into the in-browser SSP view is
2166
+ // best-effort: the manual re-materialize reads the local DB (which already
2167
+ // excludes the row), so the result is correct regardless.
2168
+ try {
2169
+ await this.cache.delete(table, id, true, beforeRecord);
2170
+ } catch (err) {
2171
+ this.logger.error(
2172
+ { err, id, Category: 'sp00ky-client::DataModule::delete' },
2173
+ 'SSP delete-ingest failed; relying on query re-materialize to reflect the delete'
2174
+ );
2175
+ }
2176
+
2177
+ // DBSP may not emit view updates for DELETE ops — manually notify all queries
2178
+ // that reference this table.
2179
+ await this.notifyTableQueries(tableName);
558
2180
 
559
2181
  // Emit mutation event
560
2182
  const mutationEvent: DeleteEvent = {
@@ -567,7 +2189,7 @@ export class DataModule<S extends SchemaStructure> {
567
2189
  callback([mutationEvent]);
568
2190
  }
569
2191
 
570
- this.logger.debug({ id, Category: 'spooky-client::DataModule::delete' }, 'Record deleted');
2192
+ this.logger.debug({ id, Category: 'sp00ky-client::DataModule::delete' }, 'Record deleted');
571
2193
  }
572
2194
 
573
2195
  // ==================== ROLLBACK METHODS ====================
@@ -579,19 +2201,17 @@ export class DataModule<S extends SchemaStructure> {
579
2201
  const id = encodeRecordId(recordId);
580
2202
 
581
2203
  try {
582
- await withRetry(this.logger, () =>
583
- this.local.query('DELETE $id', { id: recordId })
584
- );
2204
+ await withRetry(this.logger, () => this.local.query('DELETE $id', { id: recordId }));
585
2205
  await this.cache.delete(tableName, id, true);
586
2206
  this.removeRecordFromQueries(recordId);
587
2207
 
588
2208
  this.logger.info(
589
- { id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
2209
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
590
2210
  'Rolled back optimistic create'
591
2211
  );
592
2212
  } catch (err) {
593
2213
  this.logger.error(
594
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackCreate' },
2214
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackCreate' },
595
2215
  'Failed to rollback create'
596
2216
  );
597
2217
  }
@@ -626,7 +2246,7 @@ export class DataModule<S extends SchemaStructure> {
626
2246
  table: tableName,
627
2247
  op: 'UPDATE',
628
2248
  record: parsedRecord,
629
- version: (beforeRecord.spooky_rv as number) || 1,
2249
+ version: (beforeRecord._00_rv as number) || 1,
630
2250
  },
631
2251
  true
632
2252
  );
@@ -635,17 +2255,40 @@ export class DataModule<S extends SchemaStructure> {
635
2255
  await this.replaceRecordInQueries(beforeRecord);
636
2256
 
637
2257
  this.logger.info(
638
- { id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
2258
+ { id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
639
2259
  'Rolled back optimistic update'
640
2260
  );
641
2261
  } catch (err) {
642
2262
  this.logger.error(
643
- { err, id, tableName, Category: 'spooky-client::DataModule::rollbackUpdate' },
2263
+ { err, id, tableName, Category: 'sp00ky-client::DataModule::rollbackUpdate' },
644
2264
  'Failed to rollback update'
645
2265
  );
646
2266
  }
647
2267
  }
648
2268
 
2269
+ /**
2270
+ * Force a re-materialize + notify of every active query on `tableName`.
2271
+ * Used after a DELETE landed in the local store (this tab's own, or one
2272
+ * relayed from another tab): the SSP may not emit a view update for a
2273
+ * DELETE ingest, and the re-materialize reads the store, which already
2274
+ * excludes the row. Each query is isolated so one failing re-materialize
2275
+ * can't stop the others.
2276
+ */
2277
+ async notifyTableQueries(tableName: string): Promise<void> {
2278
+ for (const [queryHash, queryState] of this.activeQueries) {
2279
+ if (queryState.config.tableName === tableName) {
2280
+ try {
2281
+ await this.notifyQuerySynced(queryHash);
2282
+ } catch (err) {
2283
+ this.logger.error(
2284
+ { err, queryHash, tableName, Category: 'sp00ky-client::DataModule::notifyTableQueries' },
2285
+ 'notifyQuerySynced failed after delete'
2286
+ );
2287
+ }
2288
+ }
2289
+ }
2290
+ }
2291
+
649
2292
  /**
650
2293
  * Remove a record from all active query states and notify subscribers
651
2294
  */
@@ -678,7 +2321,9 @@ export class DataModule<S extends SchemaStructure> {
678
2321
  surqlString: string,
679
2322
  params: Record<string, any>,
680
2323
  ttl: QueryTimeToLive,
681
- tableName: T
2324
+ tableName: T,
2325
+ plan?: QueryPlan,
2326
+ membershipKey?: string
682
2327
  ): Promise<QueryHash> {
683
2328
  const queryState = await this.createNewQuery<T>({
684
2329
  recordId,
@@ -686,31 +2331,77 @@ export class DataModule<S extends SchemaStructure> {
686
2331
  params,
687
2332
  ttl,
688
2333
  tableName,
2334
+ plan,
2335
+ membershipKey,
689
2336
  });
690
2337
 
691
- const { localArray } = this.cache.registerQuery({
2338
+ const t0 = performance.now();
2339
+ const { localArray, registrationTimings } = this.cache.registerQuery({
692
2340
  queryHash: hash,
693
2341
  surql: surqlString,
694
2342
  params,
695
2343
  ttl: new Duration(ttl),
696
2344
  lastActiveAt: new Date(),
697
2345
  });
2346
+ const registrationTime = performance.now() - t0;
2347
+
2348
+ // Record the one-shot SSP registration timings (parse/plan/snapshot from the
2349
+ // WASM binding) + the register_view wall time for DevTools.
2350
+ queryState.registrationTimings = {
2351
+ parseMs: registrationTimings?.parseMs ?? null,
2352
+ planMs: registrationTimings?.planMs ?? null,
2353
+ snapshotMs: registrationTimings?.snapshotMs ?? null,
2354
+ wallMs: registrationTime,
2355
+ };
698
2356
 
699
2357
  await withRetry(this.logger, () =>
700
- this.local.query(surql.seal(surql.updateSet('id', ['localArray'])), {
701
- id: recordId,
702
- localArray,
703
- })
2358
+ this.local.query(
2359
+ surql.seal(surql.updateSet('id', ['localArray', 'registrationTime', 'rowCount'])),
2360
+ {
2361
+ id: recordId,
2362
+ localArray,
2363
+ registrationTime,
2364
+ rowCount: localArray.length,
2365
+ }
2366
+ )
704
2367
  );
705
2368
 
2369
+ // Windowed (`START n`) queries skipped the raw initial load in
2370
+ // createNewQuery (O(offset) + wrong rows for sparse windows). Seed the
2371
+ // initial rows now from the SSP's window id-set (`localArray`) via the same
2372
+ // window-materialization path the stream updates use — O(window), and the
2373
+ // ids are already the correct window — so the first paint isn't empty while
2374
+ // the remote `_00_list_ref` syncs in.
2375
+ const windowMat = buildWindowMaterialization(surqlString);
2376
+ if (windowMat && localArray.length > 0) {
2377
+ try {
2378
+ const winIds = localArray.map(([id]) => parseRecordIdString(id));
2379
+ if (plan) {
2380
+ const winPlan = buildWindowMaterializationPlan(plan, winIds) ?? { ...plan, ids: winIds };
2381
+ queryState.records = await this.local.select(winPlan, params);
2382
+ } else {
2383
+ const [seeded] = await this.local.query<[Record<string, any>[]]>(windowMat.query, {
2384
+ ...params,
2385
+ __win: winIds,
2386
+ });
2387
+ queryState.records = seeded || [];
2388
+ }
2389
+ } catch (err) {
2390
+ this.logger.warn(
2391
+ { err, hash, Category: 'sp00ky-client::DataModule::createAndRegisterQuery' },
2392
+ 'Failed to seed windowed initial records from localArray'
2393
+ );
2394
+ }
2395
+ }
2396
+
706
2397
  this.activeQueries.set(hash, queryState);
707
- this.startTTLHeartbeat(queryState);
2398
+ this.startTTLHeartbeat(queryState, hash);
708
2399
  this.logger.debug(
709
2400
  {
710
2401
  hash,
711
2402
  tableName,
712
2403
  recordCount: queryState.records.length,
713
- Category: 'spooky-client::DataModule::query',
2404
+ Category: 'sp00ky-client::DataModule::query',
714
2405
  },
715
2406
  'Query registered'
716
2407
  );
@@ -724,14 +2415,27 @@ export class DataModule<S extends SchemaStructure> {
724
2415
  params,
725
2416
  ttl,
726
2417
  tableName,
2418
+ plan,
2419
+ membershipKey,
727
2420
  }: {
728
2421
  recordId: RecordId;
729
2422
  surql: string;
730
2423
  params: Record<string, any>;
731
2424
  ttl: QueryTimeToLive;
732
2425
  tableName: T;
2426
+ plan?: QueryPlan;
2427
+ membershipKey?: string;
733
2428
  }): Promise<QueryState> {
734
- const tableSchema = this.schema.tables.find((t) => t.name === tableName);
2429
+ // `_00_*` meta tables (feature flags, app releases) are framework-owned:
2430
+ // they exist in the client db schema by construction but are never part of
2431
+ // the app's generated `schema.tables`, so give them an empty column map
2432
+ // instead of the not-found error (which silently broke every meta-table
2433
+ // live query, e.g. feature flags never updating on this path).
2434
+ const tableSchema =
2435
+ this.schema.tables.find((t) => t.name === tableName) ??
2436
+ (String(tableName).startsWith('_00_')
2437
+ ? ({ name: tableName, columns: {} } as any)
2438
+ : undefined);
735
2439
  if (!tableSchema) {
736
2440
  throw new Error(`Table ${tableName} not found`);
737
2441
  }
@@ -752,8 +2456,12 @@ export class DataModule<S extends SchemaStructure> {
752
2456
  localArray: [],
753
2457
  remoteArray: [],
754
2458
  lastActiveAt: new Date(),
2459
+ createdAt: new Date(),
755
2460
  ttl,
756
2461
  tableName,
2462
+ updateCount: 0,
2463
+ rowCount: 0,
2464
+ errorCount: 0,
757
2465
  },
758
2466
  })
759
2467
  );
@@ -763,60 +2471,233 @@ export class DataModule<S extends SchemaStructure> {
763
2471
  const config: QueryConfig = {
764
2472
  ...configRecord,
765
2473
  id: recordId,
766
- params: parseParams(tableSchema.columns, configRecord.params),
2474
+ // In-memory only — carries the engine-neutral plan so non-SurrealQL local
2475
+ // engines materialize via `select(plan)` instead of parsing `surql`.
2476
+ plan,
2477
+ // The CALLER's params, not the ones read back out of the local store: the
2478
+ // store returns a RecordId as a plain `'table:id'` string, and
2479
+ // `parseQueryParams` can only turn it back into a RecordId for a column
2480
+ // the schema marks `recordId`. A union column (`string | record<x>`)
2481
+ // codegens as a plain string, so its param stayed a string and the view
2482
+ // compared a string against a record and matched nothing. The record id is
2483
+ // the same query either way - `recordId` is the hash of surql + vars - so
2484
+ // the in-memory value is the same one, with its types intact.
2485
+ params: parseQueryParams(tableSchema.columns, params ?? configRecord.params),
2486
+ membershipKey,
767
2487
  };
768
2488
 
2489
+ // The `_00_query` row we just read is session-salted, so on a reload it is
2490
+ // always a fresh row with empty arrays. Recover the last authoritative
2491
+ // membership from the durable `_00_window` row instead — this is what stops a
2492
+ // removed row reappearing after a reload, and it works with no network.
2493
+ if (membershipKey && !config.remoteArray?.length) {
2494
+ const durable = await this.getWindowMembership(membershipKey);
2495
+ // An empty durable row is trusted only when it carries the `confirmed`
2496
+ // marker, i.e. the server itself reported the query empty. Without it an
2497
+ // empty row cannot be told apart from one written before this device ever
2498
+ // saw a real id-set (or by the pre-`ea56f50e` client that mirrored
2499
+ // unflushed reads), and treating it as known would paint an empty list
2500
+ // with no scan fallback. A confirmed empty is the opposite case: the
2501
+ // server said "no rows", so a reload must stay empty rather than re-admit
2502
+ // every cached body until the next poll blanks it again.
2503
+ if (durable && (durable.ids.length > 0 || durable.confirmed)) {
2504
+ config.remoteArray = durable.ids;
2505
+ config.membershipKnown = true;
2506
+ }
2507
+ } else if (config.remoteArray?.length) {
2508
+ config.membershipKnown = true;
2509
+ }
2510
+
769
2511
  let records: Record<string, any>[] = [];
770
- try {
771
- const [result] = await this.local.query<[Record<string, any>[]]>(surqlString, params);
772
- records = result || [];
773
- } catch (err) {
774
- this.logger.warn(
775
- { err, Category: 'spooky-client::DataModule::createNewQuery' },
776
- 'Failed to load initial cached records'
777
- );
2512
+ // Windowed (`START n`) queries: do NOT seed from the raw surql here. Running
2513
+ // `… LIMIT n START m` against the shared local store is O(m) — it sorts and
2514
+ // skips m rows on every window open — AND returns the wrong rows for sparse
2515
+ // windows (the reason `buildWindowMaterialization` exists). Those windows are
2516
+ // seeded from the SSP `localArray` in `createAndRegisterQuery` instead.
2517
+ //
2518
+ // With known membership the same reasoning applies to EVERY query, windowed
2519
+ // or not: the predicate scan below would re-admit rows the server has already
2520
+ // dropped from the window (their bodies are still cached and still match the
2521
+ // WHERE). Seed from membership instead.
2522
+ if (config.membershipKnown) {
2523
+ try {
2524
+ records = await this.materializeFromConfig(config);
2525
+ } catch (err) {
2526
+ this.logger.warn(
2527
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
2528
+ 'Failed to seed records from durable membership'
2529
+ );
2530
+ }
2531
+ } else if (buildWindowMaterialization(surqlString) === null) {
2532
+ try {
2533
+ // Prefer the engine-neutral plan (required for non-SurrealQL engines);
2534
+ // fall back to running the raw surql on the SurrealDB engine.
2535
+ const result = plan
2536
+ ? await this.local.select(plan, params)
2537
+ : (await this.local.query<[Record<string, any>[]]>(surqlString, params))[0];
2538
+ records = result || [];
2539
+ } catch (err) {
2540
+ this.logger.warn(
2541
+ { err, Category: 'sp00ky-client::DataModule::createNewQuery' },
2542
+ 'Failed to load initial cached records'
2543
+ );
2544
+ }
778
2545
  }
779
2546
 
2547
+ // Persisted counters survive a restart even though the rolling
2548
+ // sample window is rebuilt from scratch in memory.
2549
+ const persistedUpdateCount =
2550
+ typeof (configRecord as any)?.updateCount === 'number'
2551
+ ? (configRecord as any).updateCount
2552
+ : 0;
2553
+ const persistedErrorCount =
2554
+ typeof (configRecord as any)?.errorCount === 'number' ? (configRecord as any).errorCount : 0;
2555
+
780
2556
  return {
781
2557
  config,
782
2558
  records,
783
2559
  ttlTimer: null,
784
2560
  ttlDurationMs: parseDuration(ttl),
785
- updateCount: 0,
2561
+ updateCount: persistedUpdateCount,
2562
+ lastUpdatedAt: null,
2563
+ materializationSamples: [],
2564
+ lastIngestLatencyMs: null,
2565
+ errorCount: persistedErrorCount,
2566
+ // Born `fetching`, not `idle`: every cold registration is followed by a
2567
+ // `register` down-event whose lifecycle (Sp00kySync.registerQuery) resolves
2568
+ // the status to `idle` once the initial sync completed. Starting idle left
2569
+ // a gap where a fresh windowed query looked settled while still empty.
2570
+ status: 'fetching',
2571
+ phaseSamples: {},
2572
+ phaseLast: {},
2573
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
786
2574
  };
787
2575
  }
788
2576
 
789
2577
  private async calculateHash(data: any): Promise<string> {
790
- const content = JSON.stringify(data);
2578
+ // sessionId is part of the hash so the same logical query from two
2579
+ // sessions (e.g. two browser tabs of the same user) lands on different
2580
+ // `_00_query` rows and doesn't fight over a shared one.
2581
+ return this.sha256(JSON.stringify({ ...data, sessionId: this.sessionId }));
2582
+ }
2583
+
2584
+ /**
2585
+ * Session-independent counterpart of {@link calculateHash}: the key for a
2586
+ * query's durable `_00_window` membership row.
2587
+ *
2588
+ * Deliberately the SAME inputs minus the `session::id()` salt, so the two keys
2589
+ * can never drift apart. The salt is right for `_00_query` (two tabs must not
2590
+ * fight over one row) and wrong for membership, which has to be recognizable
2591
+ * after a reload — a reload mints a new session id, and offline the salt is
2592
+ * `''`, so a salted key can never match what the previous session wrote.
2593
+ */
2594
+ private async calculateMembershipKey(data: any): Promise<string> {
2595
+ return this.sha256(JSON.stringify(data));
2596
+ }
2597
+
2598
+ private async sha256(content: string): Promise<string> {
791
2599
  const msgBuffer = new TextEncoder().encode(content);
792
2600
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
793
2601
  const hashArray = Array.from(new Uint8Array(hashBuffer));
794
2602
  return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
795
2603
  }
796
2604
 
797
- private startTTLHeartbeat(queryState: QueryState): void {
2605
+ private startTTLHeartbeat(queryState: QueryState, hash: QueryHash): void {
798
2606
  if (queryState.ttlTimer) return;
799
2607
 
800
- const heartbeatTime = Math.floor(queryState.ttlDurationMs * 0.9);
2608
+ // Half the TTL, not 0.9 of it, so ONE failed beat is survivable.
2609
+ //
2610
+ // The timer re-arms after each beat, so expiry sits a full TTL after the
2611
+ // last SUCCESSFUL refresh. At 0.9 a beat that fails — the SSP restarting,
2612
+ // a reconnect, a slow database — left 10% of the TTL before the sweep
2613
+ // deleted the row AND its `_00_list_ref` edges, with no second attempt in
2614
+ // between. On a 10m TTL that is 60 seconds of slack against an SSP
2615
+ // bootstrap that takes ~2 minutes on a large tenant, so the view lost a
2616
+ // race it could not win.
2617
+ //
2618
+ // The user-visible form is content vanishing and coming back: the client
2619
+ // is LIVE on those edges, so it sees the sweep's deletes immediately, and
2620
+ // only restores them once its heartbeat notices the row is gone and
2621
+ // re-registers. Reported as chat messages disappearing for 10+ seconds.
2622
+ //
2623
+ // At 0.5 a single failure still leaves another attempt and half the TTL.
2624
+ // The cost is one extra beat per view per TTL, which is a single
2625
+ // `UPDATE ... SET lastActiveAt`.
2626
+ const heartbeatTime = Math.floor(queryState.ttlDurationMs * TTL_HEARTBEAT_FRACTION);
801
2627
 
802
2628
  queryState.ttlTimer = setTimeout(() => {
803
- // TODO: Emit heartbeat event for sync
2629
+ queryState.ttlTimer = null;
2630
+ // Only keep the remote query alive while something is actually watching
2631
+ // it. With the server now sweeping ALL expired views (not just in-circuit
2632
+ // ones), an un-refreshed query WOULD be swept after its TTL — so a live
2633
+ // subscriber must heartbeat. An abandoned query (no subscribers) is left
2634
+ // to expire and get swept; its local view was already torn down at the
2635
+ // last unsubscribe, so we just stop the timer here.
2636
+ const subscriberCount = this.subscriptions.get(hash)?.size ?? 0;
2637
+ if (subscriberCount === 0) {
2638
+ this.logger.debug(
2639
+ { hash, Category: 'sp00ky-client::DataModule::startTTLHeartbeat' },
2640
+ 'TTL heartbeat: no subscribers, stopping'
2641
+ );
2642
+ return;
2643
+ }
2644
+ queryState.lastHeartbeatAt = Date.now();
2645
+ this.onHeartbeat?.(hash);
804
2646
  this.logger.debug(
805
2647
  {
2648
+ hash,
806
2649
  id: encodeRecordId(queryState.config.id),
807
- Category: 'spooky-client::DataModule::startTTLHeartbeat',
2650
+ Category: 'sp00ky-client::DataModule::startTTLHeartbeat',
808
2651
  },
809
- 'TTL heartbeat'
2652
+ 'TTL heartbeat sent'
810
2653
  );
811
- this.startTTLHeartbeat(queryState);
2654
+ this.startTTLHeartbeat(queryState, hash);
812
2655
  }, heartbeatTime);
813
2656
  }
814
2657
 
815
- private stopTTLHeartbeat(queryState: QueryState): void {
816
- if (queryState.ttlTimer) {
817
- clearTimeout(queryState.ttlTimer);
818
- queryState.ttlTimer = null;
2658
+ /**
2659
+ * Heartbeat every watched query NOW and re-anchor its timer.
2660
+ *
2661
+ * The TTL timer is the only thing keeping a view alive on the server, and
2662
+ * browsers throttle timers in hidden tabs (Chrome aligns them to one-minute
2663
+ * ticks after five minutes hidden, and suspends them outright on some
2664
+ * mobile platforms). A tab that comes back after a long stretch may have
2665
+ * missed its beat by more than the server's slack, and the sweep will have
2666
+ * taken the row and its edges. The client does not blank on that any more
2667
+ * (`updateQueryRemoteArray` treats a missing row as "view lost"), but the
2668
+ * sooner it finds out, the sooner the view is rebuilt: `heartbeatQuery`
2669
+ * re-registers a query whose row is gone. Called on `visibilitychange` (to
2670
+ * visible) and `online`.
2671
+ *
2672
+ * Queries beaten within the last `minGapMs` are skipped, so a flurry of
2673
+ * wake events costs one beat per query. There is deliberately no local
2674
+ * expiry anywhere in this module: the TTL is the server's, and the client's
2675
+ * only duty is to keep asserting liveness while something is watching.
2676
+ */
2677
+ heartbeatOnWake(minGapMs: number = WAKE_HEARTBEAT_MIN_GAP_MS): number {
2678
+ const now = Date.now();
2679
+ let beaten = 0;
2680
+ for (const [hash, queryState] of this.activeQueries.entries()) {
2681
+ if ((this.subscriptions.get(hash)?.size ?? 0) === 0) continue;
2682
+ if (queryState.lastHeartbeatAt !== undefined && now - queryState.lastHeartbeatAt < minGapMs) {
2683
+ continue;
2684
+ }
2685
+ if (queryState.ttlTimer) {
2686
+ clearTimeout(queryState.ttlTimer);
2687
+ queryState.ttlTimer = null;
2688
+ }
2689
+ queryState.lastHeartbeatAt = now;
2690
+ this.onHeartbeat?.(hash);
2691
+ this.startTTLHeartbeat(queryState, hash);
2692
+ beaten++;
2693
+ }
2694
+ if (beaten > 0) {
2695
+ this.logger.debug(
2696
+ { beaten, Category: 'sp00ky-client::DataModule::heartbeatOnWake' },
2697
+ 'Wake heartbeat sent for watched queries'
2698
+ );
819
2699
  }
2700
+ return beaten;
820
2701
  }
821
2702
 
822
2703
  private async replaceRecordInQueries(record: Record<string, any>): Promise<void> {
@@ -851,7 +2732,7 @@ export function parseUpdateOptions(
851
2732
  const delay = options.debounced !== true ? (options.debounced?.delay ?? 200) : 200;
852
2733
  const keyType = options.debounced !== true ? (options.debounced?.key ?? id) : id;
853
2734
  const key =
854
- keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).sort().join('#')}` : id;
2735
+ keyType === 'recordId_x_fields' ? `${id}::${Object.keys(data).toSorted().join('#')}` : id;
855
2736
 
856
2737
  pushEventOptions = {
857
2738
  debounced: {