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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/AGENTS.md +34 -15
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +1136 -1846
  4. package/dist/index.js +10168 -11267
  5. package/dist/types.d.ts +22 -79
  6. package/package.json +8 -4
  7. package/skills/sp00ky-core/SKILL.md +18 -0
  8. package/src/boot/auth-flip.saga.test.ts +82 -0
  9. package/src/boot/auth-flip.saga.ts +62 -0
  10. package/src/boot/boot.saga.test.ts +151 -0
  11. package/src/boot/boot.saga.ts +128 -0
  12. package/src/boot/bucket-switch.saga.test.ts +158 -0
  13. package/src/boot/bucket-switch.saga.ts +115 -0
  14. package/src/boot/preload.saga.test.ts +51 -0
  15. package/src/boot/preload.saga.ts +31 -0
  16. package/src/client/bucket-handle.test.ts +117 -0
  17. package/src/client/bucket-handle.ts +227 -0
  18. package/src/client/router.test.ts +56 -0
  19. package/src/client/router.ts +94 -0
  20. package/src/client/runtime.test.ts +192 -0
  21. package/src/client/runtime.ts +296 -0
  22. package/src/client/services.ts +349 -0
  23. package/src/client/sp00ky-client.test.ts +241 -0
  24. package/src/client/sp00ky-client.ts +482 -0
  25. package/src/index.ts +1 -0
  26. package/src/kernel/constants.test.ts +19 -0
  27. package/src/kernel/constants.ts +54 -0
  28. package/src/kernel/effects.test.ts +40 -0
  29. package/src/kernel/effects.ts +159 -0
  30. package/src/kernel/events.ts +55 -0
  31. package/src/kernel/interpreter.test.ts +116 -0
  32. package/src/kernel/interpreter.ts +143 -0
  33. package/src/kernel/saga.test.ts +89 -0
  34. package/src/kernel/saga.ts +73 -0
  35. package/src/modules/app-release/index.ts +13 -5
  36. package/src/modules/devtools/index.ts +10 -3
  37. package/src/modules/devtools/state-shape.test.ts +4 -1
  38. package/src/modules/feature-flag/index.ts +13 -5
  39. package/src/mutation/jobs.test.ts +25 -0
  40. package/src/mutation/jobs.ts +41 -0
  41. package/src/{modules/data → mutation}/mutation-id.test.ts +12 -0
  42. package/src/mutation/push.saga.test.ts +285 -0
  43. package/src/mutation/push.saga.ts +215 -0
  44. package/src/mutation/rows.test.ts +201 -0
  45. package/src/mutation/rows.ts +377 -0
  46. package/src/mutation/tray.saga.test.ts +63 -0
  47. package/src/mutation/tray.saga.ts +48 -0
  48. package/src/mutation/write.saga.test.ts +163 -0
  49. package/src/mutation/write.saga.ts +166 -0
  50. package/src/query/env.test.ts +23 -0
  51. package/src/query/env.ts +56 -0
  52. package/src/query/fetch.saga.test.ts +156 -0
  53. package/src/query/fetch.saga.ts +109 -0
  54. package/src/query/hash.test.ts +14 -0
  55. package/src/query/hash.ts +18 -0
  56. package/src/{modules/data → query}/id-set-plan.test.ts +12 -2
  57. package/src/query/lifecycle.saga.test.ts +122 -0
  58. package/src/query/lifecycle.saga.ts +101 -0
  59. package/src/query/materialize.saga.test.ts +82 -0
  60. package/src/query/materialize.saga.ts +75 -0
  61. package/src/query/materialize.test.ts +50 -0
  62. package/src/query/materialize.ts +44 -0
  63. package/src/query/membership.saga.test.ts +246 -0
  64. package/src/query/membership.saga.ts +208 -0
  65. package/src/query/membership.test.ts +118 -0
  66. package/src/query/membership.ts +158 -0
  67. package/src/query/register.saga.test.ts +251 -0
  68. package/src/query/register.saga.ts +190 -0
  69. package/src/query/render-set.test.ts +36 -0
  70. package/src/query/render-set.ts +60 -0
  71. package/src/query/sql.test.ts +62 -0
  72. package/src/query/sql.ts +79 -0
  73. package/src/{modules/data → query}/window-query.test.ts +18 -0
  74. package/src/{modules/data → query}/window-query.ts +2 -1
  75. package/src/services/database/connection-supervisor.test.ts +29 -0
  76. package/src/services/database/connection-supervisor.ts +7 -0
  77. package/src/services/database/database.responses.test.ts +43 -0
  78. package/src/services/database/database.ts +25 -10
  79. package/src/services/database/local-migrator.ts +2 -2
  80. package/src/services/database/plan-render.test.ts +1 -1
  81. package/src/services/database/sqlite-cache-engine.test.ts +4 -3
  82. package/src/services/database/sqlite-cache-engine.ts +2 -11
  83. package/src/services/database/sqlite-devtools-queries.integration.test.ts +1 -1
  84. package/src/services/tabs/protocol.ts +7 -3
  85. package/src/sp00ky.ts +5 -1854
  86. package/src/state/client-state.ts +184 -0
  87. package/src/state/lifecycle.test.ts +118 -0
  88. package/src/state/lifecycle.ts +86 -0
  89. package/src/state/reducers.test.ts +280 -0
  90. package/src/state/reducers.ts +476 -0
  91. package/src/state/selectors.test.ts +149 -0
  92. package/src/state/selectors.ts +184 -0
  93. package/src/sync/connection.saga.test.ts +86 -0
  94. package/src/sync/connection.saga.ts +85 -0
  95. package/src/sync/live.saga.test.ts +69 -0
  96. package/src/sync/live.saga.ts +49 -0
  97. package/src/sync/policy.health.test.ts +56 -0
  98. package/src/{modules/sync/utils.test.ts → sync/policy.test.ts} +31 -3
  99. package/src/{modules/sync/utils.ts → sync/policy.ts} +67 -5
  100. package/src/sync/poll.saga.test.ts +51 -0
  101. package/src/sync/poll.saga.ts +36 -0
  102. package/src/sync/tabs.saga.test.ts +74 -0
  103. package/src/sync/tabs.saga.ts +92 -0
  104. package/src/testing/adapters.ts +133 -0
  105. package/src/testing/build.ts +61 -0
  106. package/src/testing/fake-services.ts +34 -0
  107. package/src/testing/run-pure.test.ts +92 -0
  108. package/src/testing/run-pure.ts +128 -0
  109. package/src/testing/services.ts +17 -0
  110. package/src/types.ts +8 -7
  111. package/src/utils/utils.test.ts +2 -1
  112. package/vitest.config.ts +48 -0
  113. package/coverage/lcov-report/base.css +0 -224
  114. package/coverage/lcov-report/block-navigation.js +0 -87
  115. package/coverage/lcov-report/favicon.png +0 -0
  116. package/coverage/lcov-report/index.html +0 -476
  117. package/coverage/lcov-report/prettify.css +0 -1
  118. package/coverage/lcov-report/prettify.js +0 -2
  119. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  120. package/coverage/lcov-report/sorter.js +0 -210
  121. package/coverage/lcov-report/src/boot/auth-flip.saga.ts.html +0 -271
  122. package/coverage/lcov-report/src/boot/boot.saga.ts.html +0 -469
  123. package/coverage/lcov-report/src/boot/bucket-switch.saga.ts.html +0 -430
  124. package/coverage/lcov-report/src/boot/index.html +0 -161
  125. package/coverage/lcov-report/src/boot/preload.saga.ts.html +0 -178
  126. package/coverage/lcov-report/src/client/bucket-handle.ts.html +0 -766
  127. package/coverage/lcov-report/src/client/index.html +0 -161
  128. package/coverage/lcov-report/src/client/router.ts.html +0 -367
  129. package/coverage/lcov-report/src/client/runtime.ts.html +0 -940
  130. package/coverage/lcov-report/src/client/sp00ky-client.ts.html +0 -1531
  131. package/coverage/lcov-report/src/events/index.html +0 -116
  132. package/coverage/lcov-report/src/events/index.ts.html +0 -877
  133. package/coverage/lcov-report/src/index.html +0 -146
  134. package/coverage/lcov-report/src/index.ts.html +0 -202
  135. package/coverage/lcov-report/src/kernel/constants.ts.html +0 -247
  136. package/coverage/lcov-report/src/kernel/effects.ts.html +0 -562
  137. package/coverage/lcov-report/src/kernel/events.ts.html +0 -250
  138. package/coverage/lcov-report/src/kernel/index.html +0 -176
  139. package/coverage/lcov-report/src/kernel/interpreter.ts.html +0 -514
  140. package/coverage/lcov-report/src/kernel/saga.ts.html +0 -304
  141. package/coverage/lcov-report/src/modules/app-release/index.html +0 -116
  142. package/coverage/lcov-report/src/modules/app-release/index.ts.html +0 -712
  143. package/coverage/lcov-report/src/modules/auth/events/index.html +0 -116
  144. package/coverage/lcov-report/src/modules/auth/events/index.ts.html +0 -142
  145. package/coverage/lcov-report/src/modules/auth/index.html +0 -116
  146. package/coverage/lcov-report/src/modules/auth/index.ts.html +0 -1225
  147. package/coverage/lcov-report/src/modules/crdt/crdt-field.ts.html +0 -967
  148. package/coverage/lcov-report/src/modules/crdt/index.html +0 -146
  149. package/coverage/lcov-report/src/modules/crdt/index.ts.html +0 -1474
  150. package/coverage/lcov-report/src/modules/crdt/loro-loader.ts.html +0 -160
  151. package/coverage/lcov-report/src/modules/devtools/flags.ts.html +0 -1132
  152. package/coverage/lcov-report/src/modules/devtools/index.html +0 -161
  153. package/coverage/lcov-report/src/modules/devtools/index.ts.html +0 -2413
  154. package/coverage/lcov-report/src/modules/devtools/storage-info.ts.html +0 -589
  155. package/coverage/lcov-report/src/modules/devtools/versions.ts.html +0 -415
  156. package/coverage/lcov-report/src/modules/feature-flag/index.html +0 -116
  157. package/coverage/lcov-report/src/modules/feature-flag/index.ts.html +0 -1033
  158. package/coverage/lcov-report/src/modules/index.html +0 -116
  159. package/coverage/lcov-report/src/modules/ref-tables.ts.html +0 -349
  160. package/coverage/lcov-report/src/mutation/index.html +0 -191
  161. package/coverage/lcov-report/src/mutation/jobs.ts.html +0 -208
  162. package/coverage/lcov-report/src/mutation/mutation-id.ts.html +0 -190
  163. package/coverage/lcov-report/src/mutation/push.saga.ts.html +0 -730
  164. package/coverage/lcov-report/src/mutation/rows.ts.html +0 -1216
  165. package/coverage/lcov-report/src/mutation/tray.saga.ts.html +0 -229
  166. package/coverage/lcov-report/src/mutation/write.saga.ts.html +0 -583
  167. package/coverage/lcov-report/src/otel/index.html +0 -116
  168. package/coverage/lcov-report/src/otel/index.ts.html +0 -466
  169. package/coverage/lcov-report/src/query/env.ts.html +0 -253
  170. package/coverage/lcov-report/src/query/fetch.saga.ts.html +0 -412
  171. package/coverage/lcov-report/src/query/hash.ts.html +0 -139
  172. package/coverage/lcov-report/src/query/index.html +0 -281
  173. package/coverage/lcov-report/src/query/lifecycle.saga.ts.html +0 -388
  174. package/coverage/lcov-report/src/query/materialize.saga.ts.html +0 -310
  175. package/coverage/lcov-report/src/query/materialize.ts.html +0 -217
  176. package/coverage/lcov-report/src/query/membership.saga.ts.html +0 -709
  177. package/coverage/lcov-report/src/query/membership.ts.html +0 -559
  178. package/coverage/lcov-report/src/query/register.saga.ts.html +0 -655
  179. package/coverage/lcov-report/src/query/render-set.ts.html +0 -265
  180. package/coverage/lcov-report/src/query/sql.ts.html +0 -322
  181. package/coverage/lcov-report/src/query/window-query.ts.html +0 -670
  182. package/coverage/lcov-report/src/services/blobs/blob-cache.ts.html +0 -1894
  183. package/coverage/lcov-report/src/services/blobs/blob-manifest.ts.html +0 -766
  184. package/coverage/lcov-report/src/services/blobs/blob-store.ts.html +0 -1162
  185. package/coverage/lcov-report/src/services/blobs/index.html +0 -161
  186. package/coverage/lcov-report/src/services/blobs/index.ts.html +0 -295
  187. package/coverage/lcov-report/src/services/database/cache-engine.ts.html +0 -664
  188. package/coverage/lcov-report/src/services/database/connection-supervisor.ts.html +0 -1330
  189. package/coverage/lcov-report/src/services/database/database.ts.html +0 -658
  190. package/coverage/lcov-report/src/services/database/engine-factory.ts.html +0 -184
  191. package/coverage/lcov-report/src/services/database/errors.ts.html +0 -187
  192. package/coverage/lcov-report/src/services/database/events/index.html +0 -116
  193. package/coverage/lcov-report/src/services/database/events/index.ts.html +0 -184
  194. package/coverage/lcov-report/src/services/database/index.html +0 -386
  195. package/coverage/lcov-report/src/services/database/index.ts.html +0 -121
  196. package/coverage/lcov-report/src/services/database/local-migrator.ts.html +0 -703
  197. package/coverage/lcov-report/src/services/database/local.ts.html +0 -1633
  198. package/coverage/lcov-report/src/services/database/plan-render.ts.html +0 -409
  199. package/coverage/lcov-report/src/services/database/relation-resolver.ts.html +0 -682
  200. package/coverage/lcov-report/src/services/database/remote.ts.html +0 -1036
  201. package/coverage/lcov-report/src/services/database/sqlite-cache-engine.ts.html +0 -4132
  202. package/coverage/lcov-report/src/services/database/sqlite-lock-verify.ts.html +0 -220
  203. package/coverage/lcov-report/src/services/database/sqlite-open.ts.html +0 -577
  204. package/coverage/lcov-report/src/services/database/sqlite-plan-sql.ts.html +0 -499
  205. package/coverage/lcov-report/src/services/database/sqlite-select.ts.html +0 -478
  206. package/coverage/lcov-report/src/services/database/sqlite-transport.ts.html +0 -757
  207. package/coverage/lcov-report/src/services/database/surql-translate.ts.html +0 -1333
  208. package/coverage/lcov-report/src/services/database/surreal-cache-engine.ts.html +0 -568
  209. package/coverage/lcov-report/src/services/logger/index.html +0 -116
  210. package/coverage/lcov-report/src/services/logger/index.ts.html +0 -154
  211. package/coverage/lcov-report/src/services/persistence/index.html +0 -146
  212. package/coverage/lcov-report/src/services/persistence/localstorage.ts.html +0 -163
  213. package/coverage/lcov-report/src/services/persistence/resilient.ts.html +0 -208
  214. package/coverage/lcov-report/src/services/persistence/surrealdb.ts.html +0 -271
  215. package/coverage/lcov-report/src/services/stream-processor/index.html +0 -146
  216. package/coverage/lcov-report/src/services/stream-processor/index.ts.html +0 -3313
  217. package/coverage/lcov-report/src/services/stream-processor/permissions.ts.html +0 -244
  218. package/coverage/lcov-report/src/services/stream-processor/wasm-types.ts.html +0 -346
  219. package/coverage/lcov-report/src/services/tabs/broker-client.ts.html +0 -934
  220. package/coverage/lcov-report/src/services/tabs/coordinator.ts.html +0 -1984
  221. package/coverage/lcov-report/src/services/tabs/index.html +0 -191
  222. package/coverage/lcov-report/src/services/tabs/leader-locks.ts.html +0 -310
  223. package/coverage/lcov-report/src/services/tabs/protocol.ts.html +0 -871
  224. package/coverage/lcov-report/src/services/tabs/support.ts.html +0 -193
  225. package/coverage/lcov-report/src/services/tabs/tabs-broker-worker.ts.html +0 -2005
  226. package/coverage/lcov-report/src/sp00ky.ts.html +0 -100
  227. package/coverage/lcov-report/src/state/client-state.ts.html +0 -637
  228. package/coverage/lcov-report/src/state/index.html +0 -161
  229. package/coverage/lcov-report/src/state/lifecycle.ts.html +0 -343
  230. package/coverage/lcov-report/src/state/reducers.ts.html +0 -1471
  231. package/coverage/lcov-report/src/state/selectors.ts.html +0 -637
  232. package/coverage/lcov-report/src/sync/connection.saga.ts.html +0 -343
  233. package/coverage/lcov-report/src/sync/index.html +0 -176
  234. package/coverage/lcov-report/src/sync/live.saga.ts.html +0 -232
  235. package/coverage/lcov-report/src/sync/policy.ts.html +0 -1606
  236. package/coverage/lcov-report/src/sync/poll.saga.ts.html +0 -193
  237. package/coverage/lcov-report/src/sync/tabs.saga.ts.html +0 -361
  238. package/coverage/lcov-report/src/types.ts.html +0 -2419
  239. package/coverage/lcov-report/src/utils/blurhash.ts.html +0 -355
  240. package/coverage/lcov-report/src/utils/error-classification.ts.html +0 -244
  241. package/coverage/lcov-report/src/utils/index.html +0 -191
  242. package/coverage/lcov-report/src/utils/index.ts.html +0 -820
  243. package/coverage/lcov-report/src/utils/parser.ts.html +0 -313
  244. package/coverage/lcov-report/src/utils/semver.ts.html +0 -175
  245. package/coverage/lcov-report/src/utils/surql.ts.html +0 -667
  246. package/coverage/lcov.info +0 -19079
  247. package/src/modules/cache/cache.relay.test.ts +0 -95
  248. package/src/modules/cache/index.ts +0 -361
  249. package/src/modules/cache/types.ts +0 -19
  250. package/src/modules/data/data.activity.test.ts +0 -109
  251. package/src/modules/data/data.authority.test.ts +0 -122
  252. package/src/modules/data/data.heartbeat-wake.test.ts +0 -115
  253. package/src/modules/data/data.hydration.test.ts +0 -142
  254. package/src/modules/data/data.membership.test.ts +0 -623
  255. package/src/modules/data/data.notify-table.test.ts +0 -41
  256. package/src/modules/data/data.pending-ids.test.ts +0 -199
  257. package/src/modules/data/data.rebind.test.ts +0 -170
  258. package/src/modules/data/data.rematerialize.test.ts +0 -114
  259. package/src/modules/data/data.run.test.ts +0 -113
  260. package/src/modules/data/data.settled-writes.test.ts +0 -235
  261. package/src/modules/data/data.status.test.ts +0 -249
  262. package/src/modules/data/data.test.ts +0 -58
  263. package/src/modules/data/index.ts +0 -2745
  264. package/src/modules/sync/engine.ts +0 -250
  265. package/src/modules/sync/events/index.ts +0 -84
  266. package/src/modules/sync/index.ts +0 -3
  267. package/src/modules/sync/queue/index.ts +0 -2
  268. package/src/modules/sync/queue/queue-down.test.ts +0 -228
  269. package/src/modules/sync/queue/queue-down.ts +0 -184
  270. package/src/modules/sync/queue/queue-up.forwarded.test.ts +0 -164
  271. package/src/modules/sync/queue/queue-up.ts +0 -407
  272. package/src/modules/sync/scheduler.pause.test.ts +0 -109
  273. package/src/modules/sync/scheduler.retry.test.ts +0 -237
  274. package/src/modules/sync/scheduler.ts +0 -286
  275. package/src/modules/sync/sync.cleanup.test.ts +0 -116
  276. package/src/modules/sync/sync.health.test.ts +0 -167
  277. package/src/modules/sync/sync.heartbeat.test.ts +0 -80
  278. package/src/modules/sync/sync.list-ref-meta.test.ts +0 -193
  279. package/src/modules/sync/sync.live-removal.test.ts +0 -289
  280. package/src/modules/sync/sync.reconnect.test.ts +0 -145
  281. package/src/modules/sync/sync.rematerialize-after-fetch.test.ts +0 -93
  282. package/src/modules/sync/sync.settled-poll.test.ts +0 -58
  283. package/src/modules/sync/sync.subquery.test.ts +0 -82
  284. package/src/modules/sync/sync.tabs.test.ts +0 -249
  285. package/src/modules/sync/sync.ts +0 -2626
  286. package/src/sp00ky.auth-order.test.ts +0 -112
  287. package/src/sp00ky.init-query.test.ts +0 -183
  288. package/src/sp00ky.local-first.test.ts +0 -60
  289. /package/src/{modules/data → mutation}/mutation-id.ts +0 -0
@@ -0,0 +1,60 @@
1
+ import type { RecordVersionArray } from '../types';
2
+ import type { QueryPhase } from '../state/lifecycle';
3
+ import type { Overlay } from '../state/selectors';
4
+
5
+ export interface RenderInput {
6
+ phase: QueryPhase;
7
+ remoteArray: RecordVersionArray;
8
+ localArray: RecordVersionArray;
9
+ isWindow: boolean;
10
+ }
11
+
12
+ /**
13
+ * The id-set a query renders from, or `null` when it must fall back to a
14
+ * local predicate scan (a cold query that was never resolved on this device).
15
+ * A cold window has no usable scan (re-applying `START m` against the shared
16
+ * store returns the wrong rows), so it renders the SSP's local window instead.
17
+ */
18
+ export function resolveMembership(input: RenderInput): RecordVersionArray | null {
19
+ if (input.phase !== 'cold') return input.remoteArray;
20
+ if (input.isWindow) return input.localArray;
21
+ return null;
22
+ }
23
+
24
+ export interface RenderOptions {
25
+ hasExplicitOrder: boolean;
26
+ isWindow: boolean;
27
+ }
28
+
29
+ /**
30
+ * render = (membership ∪ (writes ∩ localView)) − deletes
31
+ *
32
+ * `writes`/`deletes` are the outbox overlay (pending and acked items). The
33
+ * middle term keeps optimistic writes visible without re-admitting stale rows:
34
+ * `localView` (the SSP's local id-set) says whether the written row matches
35
+ * the query per local truth. Sorted unless the query orders itself or is a
36
+ * window (whose id order is the slice order).
37
+ */
38
+ export function buildRenderIds(
39
+ membership: RecordVersionArray,
40
+ localView: RecordVersionArray,
41
+ overlay: Overlay,
42
+ opts: RenderOptions
43
+ ): string[] {
44
+ const ordered: string[] = [];
45
+ const seen = new Set<string>();
46
+ for (const [id] of membership) {
47
+ if (overlay.deletes.has(id) || seen.has(id)) continue;
48
+ seen.add(id);
49
+ ordered.push(id);
50
+ }
51
+ if (overlay.writes.size > 0) {
52
+ for (const [id] of localView) {
53
+ if (!overlay.writes.has(id) || overlay.deletes.has(id) || seen.has(id)) continue;
54
+ seen.add(id);
55
+ ordered.push(id);
56
+ }
57
+ }
58
+ if (!opts.hasExplicitOrder && !opts.isWindow) ordered.sort();
59
+ return ordered;
60
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import * as sql from './sql';
4
+ import {
5
+ buildListRefBatchSelect,
6
+ buildListRefSelect,
7
+ buildQueryRowCountBatchSelect,
8
+ buildQueryRowCountSelect,
9
+ buildSubqueryListRefSelect,
10
+ } from '../sync/policy';
11
+
12
+ describe('query sql builders (golden against the previous builders)', () => {
13
+ it('edge and meta selects are byte-identical to the old helpers', () => {
14
+ expect(sql.listRefSelect('_00_list_ref_user_x')).toBe(buildListRefSelect('_00_list_ref_user_x'));
15
+ expect(sql.subqueryListRefSelect('t')).toBe(buildSubqueryListRefSelect('t'));
16
+ expect(sql.queryRowCountSelect()).toBe(buildQueryRowCountSelect());
17
+ expect(sql.listRefBatchSelect('t')).toBe(buildListRefBatchSelect('t'));
18
+ expect(sql.queryRowCountBatchSelect()).toBe(buildQueryRowCountBatchSelect());
19
+ });
20
+ it('composes the single, batch and register requests', () => {
21
+ expect(sql.singleSnapshotSelect('t').split(';\n')).toEqual([
22
+ sql.listRefSelect('t'),
23
+ sql.queryRowCountSelect(),
24
+ sql.subqueryListRefSelect('t'),
25
+ ]);
26
+ expect(sql.batchSnapshotSelect('t')).toBe(`${sql.listRefBatchSelect('t')};\n${sql.queryRowCountBatchSelect()}`);
27
+ expect(sql.registerSelect('t').startsWith('fn::query::register($config);\n')).toBe(true);
28
+ const id = new RecordId('_00_query', 'h');
29
+ expect(sql.registerVars({ id, surql: 's', params: { a: 1 }, ttl: '10m' })).toEqual({
30
+ config: { id, surql: 's', params: { a: 1 }, ttl: '10m' },
31
+ in: id,
32
+ });
33
+ });
34
+ it('view rows and ids', () => {
35
+ expect(sql.viewRecordId('k')).toEqual(new RecordId('_00_view', 'k'));
36
+ expect(sql.viewRow([['t:1', 1]], true, 5)).toEqual({ ids: [['t:1', 1]], confirmed: true, updatedAt: 5 });
37
+ expect(sql.readLegacyViewRows()).toBe('SELECT * FROM _00_window');
38
+ expect(sql.countViewRows()).toContain('_00_view');
39
+ expect(sql.bodySelect()).toBe('SELECT * FROM $ids');
40
+ });
41
+ it('heartbeat batch answers per index and detects reclaimed rows', () => {
42
+ const a = new RecordId('_00_query', 'a');
43
+ const b = new RecordId('_00_query', 'b');
44
+ expect(sql.heartbeatBatch([a, b])).toEqual({
45
+ sql: 'fn::query::heartbeat($id0);\nfn::query::heartbeat($id1)',
46
+ vars: { id0: a, id1: b },
47
+ });
48
+ expect(sql.heartbeatRowGone([])).toBe(true);
49
+ expect(sql.heartbeatRowGone([{ id: a }])).toBe(false);
50
+ expect(sql.heartbeatRowGone(undefined)).toBe(false);
51
+ });
52
+ it('upsertBodiesTx MERGEs each body inside one transaction', () => {
53
+ const { query, vars } = sql.upsertBodiesTx([
54
+ { id: 'thing:1', content: { a: 1 } },
55
+ { id: 'thing:2', content: { b: 2 } },
56
+ ]);
57
+ expect(query.sql).toBe(
58
+ 'BEGIN TRANSACTION;\nUPSERT ONLY $id0 MERGE $content0;UPSERT ONLY $id1 MERGE $content1;\nCOMMIT TRANSACTION;'
59
+ );
60
+ expect(vars).toEqual({ id0: 'thing:1', content0: { a: 1 }, id1: 'thing:2', content1: { b: 2 } });
61
+ });
62
+ });
@@ -0,0 +1,79 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { RecordVersionArray } from '../types';
3
+ import type { Vars } from '../kernel/effects';
4
+ import { surql } from '../utils/surql';
5
+ import type { SealedQuery } from '../utils/surql';
6
+
7
+ /** Every SurrealQL statement the query side sends, as pure builders. */
8
+
9
+ export const VIEW_TABLE = '_00_view';
10
+ export const LEGACY_VIEW_TABLE = '_00_window';
11
+
12
+ export const viewRecordId = (viewKey: string): RecordId<string> => new RecordId(VIEW_TABLE, viewKey);
13
+
14
+ export const viewRow = (ids: RecordVersionArray, confirmed: boolean, now: number): Record<string, unknown> => ({
15
+ ids,
16
+ confirmed,
17
+ updatedAt: now,
18
+ });
19
+
20
+ /** Copy every legacy `_00_window` row into `_00_view` once. */
21
+ export const readLegacyViewRows = (): string => `SELECT * FROM ${LEGACY_VIEW_TABLE}`;
22
+ export const countViewRows = (): string => `SELECT count() FROM ${VIEW_TABLE} GROUP ALL`;
23
+
24
+ export const listRefSelect = (table: string): string =>
25
+ `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NONE`;
26
+ export const subqueryListRefSelect = (table: string): string =>
27
+ `SELECT out, version FROM ${table} WHERE in = $in AND parent IS NOT NONE`;
28
+ export const queryRowCountSelect = (): string => 'SELECT VALUE { rowCount: rowCount, state: state } FROM ONLY $in';
29
+ export const listRefBatchSelect = (table: string): string =>
30
+ `SELECT in, out, version, parent FROM ${table} WHERE in IN $ins`;
31
+ export const queryRowCountBatchSelect = (): string => 'SELECT VALUE { id: id, rowCount: rowCount, state: state } FROM $ins';
32
+
33
+ /** The single-query read: edges, meta, subquery children in one request. */
34
+ export const singleSnapshotSelect = (table: string): string =>
35
+ `${listRefSelect(table)};\n${queryRowCountSelect()};\n${subqueryListRefSelect(table)}`;
36
+ /** The many-query read: all edges + all metas in one request. */
37
+ export const batchSnapshotSelect = (table: string): string =>
38
+ `${listRefBatchSelect(table)};\n${queryRowCountBatchSelect()}`;
39
+
40
+ export interface RegisterPayload {
41
+ id: RecordId<string>;
42
+ surql: string;
43
+ params: Record<string, unknown>;
44
+ ttl: string;
45
+ }
46
+
47
+ /** Register + read back edges/meta/children in ONE request. */
48
+ export const registerSelect = (table: string): string =>
49
+ `fn::query::register($config);\n${singleSnapshotSelect(table)}`;
50
+ export const registerVars = (payload: RegisterPayload): Vars => ({ config: payload, in: payload.id });
51
+
52
+ /** One heartbeat statement per view id; result index i answers id i. */
53
+ export function heartbeatBatch(ids: ReadonlyArray<RecordId<string>>): { sql: string; vars: Vars } {
54
+ const vars: Vars = {};
55
+ const stmts = ids.map((id, i) => {
56
+ vars[`id${i}`] = id;
57
+ return `fn::query::heartbeat($id${i})`;
58
+ });
59
+ return { sql: stmts.join(';\n'), vars };
60
+ }
61
+
62
+ /** A heartbeat answer whose statement matched no row: the view was reclaimed. */
63
+ export const heartbeatRowGone = (result: unknown): boolean => Array.isArray(result) && result.length === 0;
64
+
65
+ export const bodySelect = (): string => 'SELECT * FROM $ids';
66
+
67
+ /** Local transaction that MERGEs many fetched bodies. */
68
+ export function upsertBodiesTx(records: ReadonlyArray<{ id: unknown; content: Record<string, unknown> }>): {
69
+ query: SealedQuery<unknown>;
70
+ vars: Vars;
71
+ } {
72
+ const vars: Vars = {};
73
+ const stmts = records.map((r, i) => {
74
+ vars[`id${i}`] = r.id;
75
+ vars[`content${i}`] = r.content;
76
+ return surql.upsertMerge(`id${i}`, `content${i}`);
77
+ });
78
+ return { query: surql.seal<void>(surql.tx(stmts)) as SealedQuery<unknown>, vars };
79
+ }
@@ -1,6 +1,24 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { buildWindowMaterialization } from './window-query';
3
3
 
4
+ describe('scanner edge cases', () => {
5
+ it('skips escaped quotes inside strings and ignores clauses in parens', () => {
6
+ const q = "SELECT * FROM t WHERE a = 'it\\'s (FROM)' AND b IN (SELECT id FROM u LIMIT 1 START 1) LIMIT 5 START 5";
7
+ expect(buildWindowMaterialization(q)).toEqual({ query: 'SELECT * FROM $__win' });
8
+ });
9
+ it('requires whitespace inside ORDER BY and a number after START', () => {
10
+ expect(buildWindowMaterialization('SELECT * FROM t ORDERBY a LIMIT 5 START 5')).toEqual({ query: 'SELECT * FROM $__win' });
11
+ expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START $n')).toBeNull();
12
+ expect(buildWindowMaterialization('SELECT * FROM t LIMIT 5 START')).toBeNull();
13
+ });
14
+ it('matches a keyword at the very start and at the end of the string', () => {
15
+ expect(buildWindowMaterialization('FROM t LIMIT 5 START 5')).toEqual({ query: ' FROM $__win' });
16
+ expect(buildWindowMaterialization('SELECT * FROM t ORDER BY a LIMIT 5 START 5 ORDER')).toEqual({
17
+ query: 'SELECT * FROM $__win ORDER BY a',
18
+ });
19
+ });
20
+ });
21
+
4
22
  describe('buildWindowMaterialization', () => {
5
23
  it('rewrites the game-list window query (START 30) to select the id-set, keeping ORDER BY', () => {
6
24
  const surql =
@@ -104,8 +104,9 @@ export function buildWindowMaterialization(
104
104
  idsParam = '__win'
105
105
  ): { query: string } | null {
106
106
  const kw = scanTopLevelClauses(surql);
107
+ // `START` is only recorded once `FROM` was seen, so a positive start value
108
+ // implies a FROM index.
107
109
  if (kw.startValue === null || kw.startValue <= 0) return null;
108
- if (kw.fromIndex === null) return null;
109
110
  return { query: renderIdSetSelect(surql, kw, idsParam) };
110
111
  }
111
112
 
@@ -229,6 +229,35 @@ describe('ConnectionSupervisor', () => {
229
229
  sup.dispose();
230
230
  });
231
231
 
232
+ it('un-sticks the forced offline state when the SDK slept through the outage', async () => {
233
+ const listeners = new Map<string, Array<() => void>>();
234
+ vi.stubGlobal('window', {
235
+ addEventListener: (e: string, cb: () => void) => {
236
+ listeners.set(e, [...(listeners.get(e) ?? []), cb]);
237
+ },
238
+ removeEventListener: () => {},
239
+ });
240
+ const { remote, emit } = makeRemote();
241
+ const sup = makeSupervisor(remote);
242
+ emit('connected');
243
+ sup.start();
244
+
245
+ // The browser reports offline, but the SDK's own socket never notices: its
246
+ // status stays `connected` throughout, so it emits no transition at all.
247
+ listeners.get('offline')?.forEach((cb) => cb());
248
+ expect(sup.connection).toBe('disconnected');
249
+
250
+ // Back online. Nothing but the probe can correct the forced state here -
251
+ // there is no SDK event coming - and traffic flowing under a permanent
252
+ // "offline" indicator is exactly the bug.
253
+ listeners.get('online')?.forEach((cb) => cb());
254
+ await vi.advanceTimersByTimeAsync(10);
255
+ expect(remote.query).toHaveBeenCalled();
256
+ expect(sup.connection).toBe('connected');
257
+
258
+ sup.dispose();
259
+ });
260
+
232
261
  it('probes immediately when a hidden tab becomes visible', async () => {
233
262
  const listeners = new Map<string, Array<() => void>>();
234
263
  vi.stubGlobal('document', {
@@ -286,6 +286,13 @@ export class ConnectionSupervisor {
286
286
  `Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`
287
287
  );
288
288
  this.heartbeatFailures = 0;
289
+ // A round trip is the strongest evidence there is that the socket works,
290
+ // so it also RECONCILES the reported state. The `offline` handler forces
291
+ // `disconnected` without the SDK's agreement; if the SDK never noticed
292
+ // the outage it emits no transition when the network returns, and that
293
+ // forced state would otherwise stick for the life of the page - traffic
294
+ // flowing while every indicator says offline.
295
+ this.setState('connected');
289
296
  this.startHeartbeat();
290
297
  } catch (err) {
291
298
  this.heartbeatFailures++;
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { AbstractDatabaseService } from './database';
3
+ import { createDatabaseEventSystem, DatabaseEventTypes } from './events/index';
4
+
5
+ class Fake extends AbstractDatabaseService {
6
+ protected eventType = DatabaseEventTypes.RemoteQuery;
7
+ constructor(client: any) {
8
+ const noop = () => {};
9
+ const logger: any = { debug: noop, trace: noop, error: noop, info: noop, warn: noop, child: () => logger };
10
+ super(client, logger, createDatabaseEventSystem());
11
+ }
12
+ async connect() {}
13
+ }
14
+
15
+ describe('queryResponses', () => {
16
+ it('maps per-statement successes and failures without rejecting', async () => {
17
+ const client = {
18
+ query: () => ({
19
+ responses: async () => [
20
+ { success: true, result: [1] },
21
+ { success: false, error: new Error('denied') },
22
+ { success: false, error: 'plain' },
23
+ ],
24
+ }),
25
+ };
26
+ const out = await new Fake(client).queryResponses('A; B; C');
27
+ expect(out).toEqual([
28
+ { status: 'OK', result: [1] },
29
+ { status: 'ERR', error: 'denied' },
30
+ { status: 'ERR', error: 'plain' },
31
+ ]);
32
+ });
33
+ it('still rejects on a transport failure', async () => {
34
+ const client = {
35
+ query: () => ({
36
+ responses: async () => {
37
+ throw new Error('socket closed');
38
+ },
39
+ }),
40
+ };
41
+ await expect(new Fake(client).queryResponses('A')).rejects.toThrow('socket closed');
42
+ });
43
+ });
@@ -104,6 +104,30 @@ export abstract class AbstractDatabaseService {
104
104
  * SDK's pending map.
105
105
  */
106
106
  async query<T extends unknown[]>(query: string, vars?: Record<string, unknown>): Promise<T> {
107
+ return this.runStatement<T>(query, vars, () => this.client.query(query, vars) as unknown as Promise<T>);
108
+ }
109
+
110
+ /**
111
+ * Like {@link query} but never rejects on a statement error: every
112
+ * statement answers on its own as `{ status: 'OK', result }` or
113
+ * `{ status: 'ERR', error }`. This is what lets a batched outbox push judge
114
+ * each mutation separately.
115
+ */
116
+ async queryResponses(
117
+ query: string,
118
+ vars?: Record<string, unknown>
119
+ ): Promise<Array<{ status: 'OK'; result: unknown } | { status: 'ERR'; error: string }>> {
120
+ return this.runStatement(query, vars, async () => {
121
+ const responses = await this.client.query(query, vars).responses();
122
+ return responses.map((r) =>
123
+ r.success
124
+ ? { status: 'OK' as const, result: r.result }
125
+ : { status: 'ERR' as const, error: r.error instanceof Error ? r.error.message : String(r.error) }
126
+ );
127
+ });
128
+ }
129
+
130
+ private async runStatement<T>(query: string, vars: Record<string, unknown> | undefined, dispatch: () => Promise<T>): Promise<T> {
107
131
  await this.acquireQuerySlot();
108
132
  try {
109
133
  await this.beforeQuery();
@@ -113,20 +137,12 @@ export abstract class AbstractDatabaseService {
113
137
  { query, vars, Category: 'sp00ky-client::Database::query' },
114
138
  'Executing query'
115
139
  );
116
- const pending = this.client.query(query, vars);
117
- // In SurrealDB 2.0, .query() collects results by default.
118
- // We cast to T directly as proper typing depends on the caller knowing the return structure.
119
140
  // "timed out" in the message is load-bearing: `classifySyncError`
120
141
  // keys off it to classify this as `network` so the sync queues
121
142
  // retry rather than rolling the mutation back.
122
- const result = (await withTimeout(
123
- pending as unknown as Promise<T>,
124
- this.queryTimeoutMs,
125
- () => this.timeoutError(query)
126
- )) as T;
143
+ const result = await withTimeout(dispatch(), this.queryTimeoutMs, () => this.timeoutError(query));
127
144
  const duration = performance.now() - startTime;
128
145
 
129
- // Emit query event
130
146
  this.events.emit(this.eventType, {
131
147
  query,
132
148
  vars,
@@ -143,7 +159,6 @@ export abstract class AbstractDatabaseService {
143
159
  } catch (err) {
144
160
  const duration = performance.now() - startTime;
145
161
 
146
- // Emit query event with error
147
162
  this.events.emit(this.eventType, {
148
163
  query,
149
164
  vars,
@@ -44,9 +44,9 @@ export class LocalMigrator {
44
44
 
45
45
  const systemSchema = `
46
46
  DEFINE TABLE IF NOT EXISTS _00_stream_processor_state SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
- DEFINE TABLE IF NOT EXISTS _00_query SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
48
- DEFINE TABLE IF NOT EXISTS _00_preload SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
47
+ DEFINE TABLE IF NOT EXISTS _00_view SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
48
  DEFINE TABLE IF NOT EXISTS _00_window SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
49
+ DEFINE TABLE IF NOT EXISTS _00_failed_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
50
50
  DEFINE TABLE IF NOT EXISTS _00_schema SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
51
51
  DEFINE TABLE IF NOT EXISTS _00_pending_mutations SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
52
52
  DEFINE TABLE IF NOT EXISTS _00_blob SCHEMALESS PERMISSIONS FOR select, create, update, delete WHERE true;
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import type { QueryPlan } from '@spooky-sync/query-builder';
3
3
  import { renderBaseSelectSurql, renderRelationFetchSurql } from './plan-render';
4
- import { buildWindowMaterializationPlan } from '../../modules/data/window-query';
4
+ import { buildWindowMaterializationPlan } from '../../query/window-query';
5
5
 
6
6
  describe('renderBaseSelectSurql', () => {
7
7
  it('renders projection, where, order, limit, offset', () => {
@@ -91,7 +91,8 @@ describe('SqliteCacheEngine system-table seeding', () => {
91
91
 
92
92
  await engine.connect('user:fresh');
93
93
  const open = msgs.find((m) => m.type === 'open');
94
- expect(open?.payload?.systemTables).toContain('_00_query');
94
+ expect(open?.payload?.systemTables).toContain('_00_view');
95
+ expect(open?.payload?.systemTables).toContain('_00_failed_mutations');
95
96
  expect(open?.payload?.systemTables).toContain('_00_pending_mutations');
96
97
  });
97
98
  });
@@ -271,7 +272,7 @@ describe('SqliteCacheEngine role modes', () => {
271
272
  return port;
272
273
  }
273
274
 
274
- it('adoptOwner opens under the worker lock and wipes stale _00_query rows', async () => {
275
+ it('adoptOwner opens under the worker lock without wiping anything', async () => {
275
276
  const { engine, log } = makeSharedEngine();
276
277
  const health = await engine.adoptOwner('anon', {
277
278
  workerLockName: 'sp00ky-tabs:fp:anon:worker:1',
@@ -281,7 +282,7 @@ describe('SqliteCacheEngine role modes', () => {
281
282
  expect(health.status).toBe('persistent');
282
283
  expect(engine.storageHealth.role).toBe('leader');
283
284
  expect(log[0]).toBe('open');
284
- expect(log).toContain('run'); // the DELETE _00_query wipe
285
+ expect(log).not.toContain('run'); // no local _00_query table any more, nothing to wipe
285
286
  expect(engine.currentBucketId).toBe('anon');
286
287
  });
287
288
 
@@ -60,9 +60,9 @@ const SYSTEM_TABLES = [
60
60
  // In-browser circuit snapshot: one BLOB row (`circuit`) + a JSON meta row.
61
61
  // Excluded from DevTools' table listing, since its data column is not JSON.
62
62
  '_00_circuit_snapshot',
63
- '_00_query',
64
- '_00_preload',
63
+ '_00_view',
65
64
  '_00_window',
65
+ '_00_failed_mutations',
66
66
  '_00_schema',
67
67
  '_00_pending_mutations',
68
68
  // Blob cache manifest. Read before its first write on every boot (reconcile
@@ -605,15 +605,6 @@ export class SqliteCacheEngine implements LocalStore {
605
605
  disallowMemoryFallback: !opts.allowMemoryFallback,
606
606
  },
607
607
  });
608
- // Leader wipe-on-pool-open: local `_00_query` rows from earlier sessions
609
- // are dead (query hashes are session-salted) and this is the one moment
610
- // no other tab is attached, so clearing here replaces the per-switch
611
- // DELETE that solo mode does in doSwitchBucket. Followers never wipe.
612
- try {
613
- await this.rawCall('run', { sql: 'DELETE FROM "_00_query"' });
614
- } catch {
615
- /* fresh bucket: table just seeded, nothing to wipe */
616
- }
617
608
  getStats().roleChanges = (getStats().roleChanges ?? 0) + 1;
618
609
  this.hadStore = true;
619
610
  this.closeRoleGate();
@@ -122,7 +122,7 @@ describe('DevTools explorer against real SQLite', () => {
122
122
  expect(Object.keys(info.tables)).toContain('game_insight');
123
123
  // Seeded system tables show up too — the panel's "internal" toggle filters
124
124
  // them, the engine must not pre-filter.
125
- expect(Object.keys(info.tables)).toContain('_00_query');
125
+ expect(Object.keys(info.tables)).toContain('_00_view');
126
126
  expect(Object.keys(info.tables).some((t) => t.startsWith('sqlite_'))).toBe(false);
127
127
  });
128
128
 
@@ -247,12 +247,16 @@ export type LeaderToFollowerMessage =
247
247
  error: string;
248
248
  }
249
249
  /** The leader's drain pushed a mutation and deleted its outbox row from the
250
- * SHARED store. Every follower starts its settled-write grace so a row it
251
- * was rendering as a pending write does not blink out before its
250
+ * SHARED store. Every follower keeps the row in its overlay until its
252
251
  * `_00_list_ref` membership arrives. */
253
252
  | {
254
253
  type: 'mutation-settled';
255
254
  mutationId: string;
256
255
  recordId: string;
257
256
  eventType: 'create' | 'update' | 'delete';
258
- };
257
+ }
258
+ /** The leader's LIVE saw edges of these queries change; followers re-read
259
+ * membership for the ones they hold. */
260
+ | { type: 'membership-dirty'; hashes: string[] }
261
+ /** The failed-writes tray changed size on the shared store. */
262
+ | { type: 'failed-mutations-changed'; count: number };