@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
@@ -1,237 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { SyncScheduler } from './scheduler';
3
- import type { UpQueue, DownQueue, DownEvent, UpEvent } from './queue/index';
4
-
5
- // A queue whose drain throws re-queues the failing item at the HEAD and stops
6
- // the pass (see DownQueue.next). Nothing used to re-arm it: the queues only
7
- // moved on a fresh enqueue, so one transient failure — canonically the SSP
8
- // answering 503 NOT_READY for the whole of its bootstrap window — parked every
9
- // pending `register` forever, and the `useQuery` waiting on it never left its
10
- // loading state. These cover the backoff that makes that self-heal.
11
-
12
- const silentLogger = {
13
- child: () => silentLogger,
14
- debug: () => {},
15
- info: () => {},
16
- warn: () => {},
17
- error: () => {},
18
- } as any;
19
-
20
- /** A queue that mirrors the real ones: a throwing handler re-heads the item. */
21
- function makeQueue<E>(items: E[]) {
22
- return {
23
- queue: [...items],
24
- get size() {
25
- return this.queue.length;
26
- },
27
- events: { subscribe: () => {} },
28
- loadFromDatabase: async () => {},
29
- clear() {
30
- this.queue = [];
31
- },
32
- async next(fn: (event: E) => Promise<void>) {
33
- const event = this.queue.shift();
34
- if (!event) return;
35
- const err = await this.run(event, fn);
36
- if (err !== undefined) throw err;
37
- },
38
- // The concurrent down drain takes events itself and runs them, so the fake
39
- // has to mirror that half of the real DownQueue too: skip events whose hash
40
- // is already in flight, and hand the error back rather than throwing.
41
- takeNext(busy: ReadonlySet<string>) {
42
- for (let i = 0; i < this.queue.length; i++) {
43
- const event = this.queue[i] as unknown as { payload?: { hash?: string } };
44
- const hash = event?.payload?.hash;
45
- if (hash !== undefined && busy.has(hash)) continue;
46
- return this.queue.splice(i, 1)[0];
47
- }
48
- return undefined;
49
- },
50
- async run(event: E, fn: (event: E) => Promise<void>) {
51
- try {
52
- await fn(event);
53
- return undefined;
54
- } catch (err) {
55
- this.queue.unshift(event);
56
- return err;
57
- }
58
- },
59
- };
60
- }
61
-
62
- const downEvent = (hash: string) => ({ type: 'register', payload: { hash } }) as DownEvent;
63
- const upEvent = (n: number) => ({ type: 'delete', mutation_id: n, record_id: n }) as unknown as UpEvent;
64
-
65
- describe('SyncScheduler retry', () => {
66
- beforeEach(() => vi.useFakeTimers());
67
- afterEach(() => vi.useRealTimers());
68
-
69
- it('re-drains a failed down event instead of parking it forever', async () => {
70
- const downQueue = makeQueue([downEvent('q1')]);
71
- const upQueue = makeQueue<UpEvent>([]);
72
- let attempts = 0;
73
- const scheduler = new SyncScheduler(
74
- upQueue as unknown as UpQueue,
75
- downQueue as unknown as DownQueue,
76
- async () => {},
77
- async () => {
78
- attempts++;
79
- // Fail the way a bootstrapping SSP does, then succeed.
80
- if (attempts < 3) throw new Error('503 NOT_READY');
81
- },
82
- silentLogger
83
- );
84
-
85
- await scheduler.syncDown();
86
- expect(attempts).toBe(1);
87
- expect(downQueue.size).toBe(1); // re-headed, not dropped
88
-
89
- // Backoff: 500ms, then 1000ms.
90
- await vi.advanceTimersByTimeAsync(500);
91
- expect(attempts).toBe(2);
92
- await vi.advanceTimersByTimeAsync(1000);
93
- expect(attempts).toBe(3);
94
-
95
- expect(downQueue.size).toBe(0);
96
- });
97
-
98
- it('stops retrying once the queue drains', async () => {
99
- const downQueue = makeQueue([downEvent('q1')]);
100
- const upQueue = makeQueue<UpEvent>([]);
101
- let attempts = 0;
102
- const scheduler = new SyncScheduler(
103
- upQueue as unknown as UpQueue,
104
- downQueue as unknown as DownQueue,
105
- async () => {},
106
- async () => {
107
- attempts++;
108
- throw new Error('boom');
109
- },
110
- silentLogger
111
- );
112
-
113
- await scheduler.syncDown();
114
- await vi.advanceTimersByTimeAsync(500);
115
- expect(attempts).toBe(2);
116
-
117
- // Drain it out from under the scheduler; the next retry finds nothing and
118
- // schedules no further work.
119
- downQueue.clear();
120
- await vi.advanceTimersByTimeAsync(1000);
121
- expect(attempts).toBe(2);
122
- await vi.advanceTimersByTimeAsync(60_000);
123
- expect(attempts).toBe(2);
124
- });
125
-
126
- it('comes back for the down queue while the up queue holds the floor', async () => {
127
- const downQueue = makeQueue([downEvent('q1')]);
128
- const upQueue = makeQueue<UpEvent>([upEvent(1)]);
129
- let downAttempts = 0;
130
- const scheduler = new SyncScheduler(
131
- upQueue as unknown as UpQueue,
132
- downQueue as unknown as DownQueue,
133
- async () => {},
134
- async () => {
135
- downAttempts++;
136
- },
137
- silentLogger
138
- );
139
-
140
- // Yields to the non-empty up queue.
141
- await scheduler.syncDown();
142
- expect(downAttempts).toBe(0);
143
-
144
- // Once the up queue empties, the re-armed pass picks the down event up
145
- // without needing a fresh enqueue.
146
- upQueue.clear();
147
- await vi.advanceTimersByTimeAsync(500);
148
- expect(downAttempts).toBe(1);
149
- });
150
-
151
- it('pause cancels pending retries', async () => {
152
- const downQueue = makeQueue([downEvent('q1')]);
153
- const upQueue = makeQueue<UpEvent>([]);
154
- let attempts = 0;
155
- const scheduler = new SyncScheduler(
156
- upQueue as unknown as UpQueue,
157
- downQueue as unknown as DownQueue,
158
- async () => {},
159
- async () => {
160
- attempts++;
161
- throw new Error('boom');
162
- },
163
- silentLogger
164
- );
165
-
166
- await scheduler.syncDown();
167
- expect(attempts).toBe(1);
168
-
169
- await scheduler.pause();
170
- await vi.advanceTimersByTimeAsync(60_000);
171
- expect(attempts).toBe(1);
172
- });
173
- });
174
-
175
- describe('SyncScheduler down concurrency', () => {
176
- beforeEach(() => vi.useFakeTimers());
177
- afterEach(() => vi.useRealTimers());
178
-
179
- /** Scheduler over a down queue of `hashes`, whose handler blocks until the
180
- * returned `release` is called for that hash. Lets a test observe exactly
181
- * which events are in flight at the same moment. */
182
- function makeConcurrent(hashes: string[]) {
183
- const downQueue = makeQueue(hashes.map(downEvent));
184
- const upQueue = makeQueue<UpEvent>([]);
185
- const started: string[] = [];
186
- const gates = new Map<string, () => void>();
187
- const scheduler = new SyncScheduler(
188
- upQueue as unknown as UpQueue,
189
- downQueue as unknown as DownQueue,
190
- async () => {},
191
- async (event) => {
192
- const hash = (event as { payload: { hash: string } }).payload.hash;
193
- started.push(hash);
194
- await new Promise<void>((resolve) => gates.set(hash, resolve));
195
- },
196
- silentLogger
197
- );
198
- return { scheduler, started, release: (h: string) => gates.get(h)?.() };
199
- }
200
-
201
- it('runs several independent hashes at once instead of one at a time', async () => {
202
- // The bug this whole change exists for: strictly serial draining meant one
203
- // registration RPC at a time for the entire client.
204
- const { scheduler, started, release } = makeConcurrent(['a', 'b', 'c', 'd', 'e']);
205
- const pass = scheduler.syncDown();
206
- await vi.advanceTimersByTimeAsync(0);
207
-
208
- // Bounded by MAX_CONCURRENT_DOWN (4), not 1.
209
- expect(started).toEqual(['a', 'b', 'c', 'd']);
210
-
211
- // Freeing one slot immediately admits the next event.
212
- release('a');
213
- await vi.advanceTimersByTimeAsync(0);
214
- expect(started).toEqual(['a', 'b', 'c', 'd', 'e']);
215
-
216
- for (const h of ['b', 'c', 'd', 'e']) release(h);
217
- await pass;
218
- });
219
-
220
- it('never overlaps two events for the SAME hash', async () => {
221
- // Per-hash ordering is the one invariant the serial queue was providing.
222
- const { scheduler, started, release } = makeConcurrent(['a', 'a', 'b']);
223
- const pass = scheduler.syncDown();
224
- await vi.advanceTimersByTimeAsync(0);
225
-
226
- // The second 'a' is skipped while the first is in flight; 'b' goes ahead.
227
- expect(started).toEqual(['a', 'b']);
228
-
229
- release('a');
230
- await vi.advanceTimersByTimeAsync(0);
231
- expect(started).toEqual(['a', 'b', 'a']);
232
-
233
- release('b');
234
- release('a');
235
- await pass;
236
- });
237
- });
@@ -1,286 +0,0 @@
1
- import type { Logger } from '../../services/logger/index';
2
- import type { UpQueue, DownQueue, DownEvent, UpEvent, RollbackCallback } from './queue/index';
3
- import { SyncQueueEventTypes } from './events/index';
4
-
5
- /**
6
- * SyncScheduler manages when to sync: queue management and orchestration.
7
- * Decides the order and timing of sync operations.
8
- */
9
- /** Backoff for re-draining a queue that halted on an error. */
10
- const RETRY_BASE_MS = 500;
11
- /**
12
- * How many down events may be in flight at once.
13
- *
14
- * The down queue was strictly serial: one `register`/`sync`/`cleanup` RPC at a
15
- * time for the WHOLE client. Measured in production that drained at roughly one
16
- * event per 8.8s, so a list that registers a query per scrolled-to window took
17
- * minutes to fill — the rows were already cached, only the registration lagged.
18
- * Ordering is a per-hash requirement, not a global one (see `takeNext`), so
19
- * independent hashes can go in parallel. Bounded to stay polite to the SSP.
20
- */
21
- const MAX_CONCURRENT_DOWN = 4;
22
- const RETRY_MAX_MS = 15_000;
23
-
24
- export class SyncScheduler {
25
- private isSyncingUp: boolean = false;
26
- private isSyncingDown: boolean = false;
27
- private paused: boolean = false;
28
- private pauseWaiters: Array<() => void> = [];
29
- // A failed drain re-queues its item at the HEAD (see DownQueue.next) and
30
- // stops the pass. Without a timer nothing ever drains it again: the queues
31
- // only move on a fresh enqueue, so a transient failure — canonically the
32
- // SSP answering 503 NOT_READY for the whole of its bootstrap window — left
33
- // every pending `register` parked forever and its `useQuery` loading forever.
34
- // Retry on a backoff so that heals itself instead of needing a reload.
35
- private upRetryTimer?: ReturnType<typeof setTimeout>;
36
- private downRetryTimer?: ReturnType<typeof setTimeout>;
37
- private upRetryAttempt = 0;
38
- private downRetryAttempt = 0;
39
-
40
- constructor(
41
- private upQueue: UpQueue,
42
- private downQueue: DownQueue,
43
- private onProcessUp: (event: UpEvent) => Promise<void>,
44
- private onProcessDown: (event: DownEvent) => Promise<void>,
45
- private logger: Logger,
46
- private onRollback?: RollbackCallback,
47
- // Reports the outcome of each drained sync round (one syncUp/syncDown pass
48
- // that actually processed ≥1 item): `ok=true` on a clean drain, `ok=false`
49
- // with the error when the round halted on a failure. Drives the consumer's
50
- // sync-health tracking; empty/no-op rounds report nothing.
51
- private onSyncOutcome?: (ok: boolean, error?: unknown) => void,
52
- // Reports each mutation the server accepted, once its outbox row is gone.
53
- // Lets the consumer keep the row rendered until its membership arrives.
54
- private onSettled?: (event: UpEvent) => void
55
- ) {}
56
-
57
- async init(opts: { loadOutbox?: boolean } = {}) {
58
- // Shared-tabs followers skip the outbox load: the shared store's
59
- // `_00_pending_mutations` is drained by exactly one tab (the leader).
60
- if (opts.loadOutbox !== false) await this.upQueue.loadFromDatabase();
61
- this.upQueue.events.subscribe(SyncQueueEventTypes.MutationEnqueued, this.syncUp.bind(this));
62
- this.downQueue.events.subscribe(
63
- SyncQueueEventTypes.QueryItemEnqueued,
64
- this.syncDown.bind(this)
65
- );
66
- }
67
-
68
- /**
69
- * Add mutations to the upload queue
70
- */
71
- enqueueMutation(mutations: UpEvent[]) {
72
- for (const mutation of mutations) {
73
- this.upQueue.push(mutation);
74
- }
75
- }
76
-
77
- /**
78
- * Add query events to the download queue
79
- */
80
- enqueueDownEvent(event: DownEvent) {
81
- this.downQueue.push(event);
82
- }
83
-
84
- /**
85
- * Suspend syncing for a local-bucket switch. Refuses new rounds and resolves
86
- * once any in-flight round has finished — the pause point is BETWEEN queue
87
- * items, never between an item's remote push and its outbox-row delete, so a
88
- * processed mutation's `DELETE _00_pending_mutations` always lands in the
89
- * store it was read from.
90
- */
91
- pause(): Promise<void> {
92
- this.paused = true;
93
- this.clearRetryTimers();
94
- if (!this.isSyncingUp && !this.isSyncingDown) return Promise.resolve();
95
- return new Promise<void>((resolve) => this.pauseWaiters.push(resolve));
96
- }
97
-
98
- resume(): void {
99
- this.paused = false;
100
- // A resume is a fresh start, not a continuation of the failing streak.
101
- this.upRetryAttempt = 0;
102
- this.downRetryAttempt = 0;
103
- void this.syncUp();
104
- void this.syncDown();
105
- }
106
-
107
- private maybeResolvePause() {
108
- if (!this.paused || this.isSyncingUp || this.isSyncingDown) return;
109
- const waiters = this.pauseWaiters;
110
- this.pauseWaiters = [];
111
- for (const resolve of waiters) resolve();
112
- }
113
-
114
- /** Exponential backoff, capped. Attempt 0 is the first retry. */
115
- private retryDelay(attempt: number): number {
116
- return Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
117
- }
118
-
119
- private scheduleUpRetry() {
120
- if (this.paused || this.upRetryTimer || this.upQueue.size === 0) return;
121
- const delay = this.retryDelay(this.upRetryAttempt++);
122
- this.upRetryTimer = setTimeout(() => {
123
- this.upRetryTimer = undefined;
124
- void this.syncUp();
125
- }, delay);
126
- }
127
-
128
- /**
129
- * Re-arm the down pass. With no argument this is failure backoff and the
130
- * streak grows; with an explicit delay it is a yield (the up-queue holds the
131
- * floor), which is not a failure and must not push the backoff out.
132
- */
133
- private scheduleDownRetry(delayMs?: number) {
134
- if (this.paused || this.downRetryTimer || this.downQueue.size === 0) return;
135
- const delay = delayMs ?? this.retryDelay(this.downRetryAttempt++);
136
- this.downRetryTimer = setTimeout(() => {
137
- this.downRetryTimer = undefined;
138
- void this.syncDown();
139
- }, delay);
140
- }
141
-
142
- private clearRetryTimers() {
143
- if (this.upRetryTimer) {
144
- clearTimeout(this.upRetryTimer);
145
- this.upRetryTimer = undefined;
146
- }
147
- if (this.downRetryTimer) {
148
- clearTimeout(this.downRetryTimer);
149
- this.downRetryTimer = undefined;
150
- }
151
- }
152
-
153
- /** Stop all pending retries. Call when tearing the client down. */
154
- dispose(): void {
155
- this.paused = true;
156
- this.clearRetryTimers();
157
- }
158
-
159
- /**
160
- * Process upload queue
161
- */
162
- async syncUp() {
163
- if (this.isSyncingUp || this.paused) return;
164
- this.isSyncingUp = true;
165
- let processedAny = false;
166
- try {
167
- while (this.upQueue.size > 0 && !this.paused) {
168
- await this.upQueue.next(this.onProcessUp, this.onRollback, this.onSettled);
169
- processedAny = true;
170
- }
171
- if (processedAny) this.onSyncOutcome?.(true);
172
- this.upRetryAttempt = 0;
173
- } catch (error) {
174
- this.onSyncOutcome?.(false, error);
175
- this.scheduleUpRetry();
176
- // syncUp runs fire-and-forget — it's wired to the MutationEnqueued event
177
- // (broadcast synchronously, return value dropped) and is also kicked off
178
- // via `void this.syncDown()` below. A rejection escaping here therefore
179
- // surfaces as an *unhandled promise rejection* in the console rather than
180
- // anything a caller can catch. UpQueue.next already logs the failing item
181
- // (and re-queues it for retry on the next trigger), so swallow here to
182
- // keep the failure contained instead of leaking it globally.
183
- this.logger.debug(
184
- { error, Category: 'sp00ky-client::SyncScheduler::syncUp' },
185
- 'syncUp halted on a queue error; item re-queued, will retry on next trigger'
186
- );
187
- } finally {
188
- this.isSyncingUp = false;
189
- this.maybeResolvePause();
190
- void this.syncDown();
191
- }
192
- }
193
-
194
- /**
195
- * Process download queue
196
- */
197
- async syncDown() {
198
- if (this.isSyncingDown || this.paused) return;
199
- // Down-sync yields to a non-empty up-queue so a register never races ahead
200
- // of the mutation it should observe. That yield used to be permanent: if
201
- // the up-queue never drained, nothing re-armed the down pass. Come back on
202
- // the backoff instead, so a wedged push delays reads rather than killing
203
- // them.
204
- if (this.upQueue.size > 0) {
205
- this.scheduleDownRetry(RETRY_BASE_MS);
206
- return;
207
- }
208
-
209
- this.isSyncingDown = true;
210
- let processedAny = false;
211
- // Hashes currently in flight. `takeNext` skips their events, so per-hash
212
- // ordering holds while independent hashes run concurrently.
213
- const busy = new Set<string>();
214
- const inFlight = new Set<Promise<void>>();
215
- // First failure of this pass. Once set we stop TAKING work (mirroring the
216
- // old "halt the pass and retry on a backoff") but still let what is already
217
- // in flight finish, rather than abandoning RPCs mid-round.
218
- let failure: unknown | undefined;
219
- try {
220
- for (;;) {
221
- if (this.paused) break;
222
- // Yield to the up queue: stop taking new down work so a pending
223
- // mutation gets its turn. The old code `break`-ed out and came back
224
- // only on a backoff, so a steady trickle of local writes could starve
225
- // registration indefinitely; draining what is in flight and re-arming
226
- // immediately interleaves the two instead.
227
- const yieldToUp = this.upQueue.size > 0;
228
- while (
229
- failure === undefined &&
230
- !yieldToUp &&
231
- inFlight.size < MAX_CONCURRENT_DOWN
232
- ) {
233
- const event = this.downQueue.takeNext(busy);
234
- if (!event) break;
235
- const hash = event.payload.hash;
236
- busy.add(hash);
237
- processedAny = true;
238
- // `run` never rejects — it hands the error back — so one failing
239
- // event cannot reject the `Promise.race` below and lose the rest.
240
- const task: Promise<void> = this.downQueue
241
- .run(event, this.onProcessDown)
242
- .then((error) => {
243
- if (error !== undefined && failure === undefined) failure = error;
244
- })
245
- .finally(() => {
246
- busy.delete(hash);
247
- inFlight.delete(task);
248
- });
249
- inFlight.add(task);
250
- }
251
- if (inFlight.size === 0) break;
252
- // Wake as soon as ANY slot frees, so the pool refills continuously
253
- // instead of in barriers.
254
- await Promise.race(inFlight);
255
- if (yieldToUp && inFlight.size === 0) break;
256
- }
257
- if (failure !== undefined) throw failure;
258
- if (processedAny) this.onSyncOutcome?.(true);
259
- this.downRetryAttempt = 0;
260
- // Re-arm if we stopped early for the up queue (or hit the pool cap with
261
- // work still queued) — otherwise that work waits for a fresh enqueue.
262
- if (this.downQueue.size > 0) this.scheduleDownRetry(RETRY_BASE_MS);
263
- } catch (error) {
264
- this.onSyncOutcome?.(false, error);
265
- this.scheduleDownRetry();
266
- // Same fire-and-forget story as syncUp: this is the QueryItemEnqueued
267
- // subscriber (and is also called via `void this.syncDown()`), so a thrown
268
- // error here becomes an unhandled rejection. The canonical case is a
269
- // transient remote 500 on `fn::query::register` — DownQueue.next logs it
270
- // and re-queues the event at the head; we just stop draining this pass and
271
- // let the next enqueue retry, without spamming the console with an
272
- // "Uncaught (in promise) ... 500 Internal Server Error".
273
- this.logger.debug(
274
- { error, Category: 'sp00ky-client::SyncScheduler::syncDown' },
275
- 'syncDown halted on a queue error; item re-queued, will retry on next trigger'
276
- );
277
- } finally {
278
- this.isSyncingDown = false;
279
- this.maybeResolvePause();
280
- }
281
- }
282
-
283
- get isSyncing() {
284
- return this.isSyncingUp || this.isSyncingDown;
285
- }
286
- }
@@ -1,116 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { RecordId } from 'surrealdb';
3
- import { Sp00kySync } from './sync';
4
-
5
- // Guards the teardown half of shared views. A `_00_query` row can be shared by
6
- // several sessions of the same user, so releasing it MUST go through
7
- // `fn::query::unsubscribe` — which drops only this session from `subscribers`
8
- // and deletes the row when it was the last one. A bare `DELETE $id` here would
9
- // destroy the view and every `_00_list_ref` edge hanging off it for every other
10
- // live tab, which reads to the user as a list that silently goes blank.
11
- //
12
- // This was previously unenforceable: `_00_query` granted no delete permission,
13
- // so the old `DELETE $id` affected zero rows and did nothing at all. Now that
14
- // the table grants delete, a regression here is destructive rather than inert.
15
-
16
- function makeSync(opts: { hasSubscribers?: boolean[] } = {}) {
17
- const logger: any = {
18
- child: () => logger,
19
- debug: () => {}, info: () => {}, warn: () => {}, error: () => {}, trace: () => {},
20
- };
21
- const remote: any = { query: vi.fn().mockResolvedValue([{ released: true, remaining: 0 }]) };
22
- const queryId = new RecordId('_00_query', 'h1');
23
- const queryState: any = { config: { id: queryId } };
24
-
25
- // Successive hasSubscribers() answers: [before the release, after it].
26
- const answers = opts.hasSubscribers ?? [false, false];
27
- let call = 0;
28
- const hasSubscribers = vi.fn(() => answers[Math.min(call++, answers.length - 1)]);
29
-
30
- const finalizeDeregister = vi.fn();
31
- const dataModule: any = {
32
- getQueryByHash: vi.fn().mockReturnValue(queryState),
33
- hasSubscribers,
34
- finalizeDeregister,
35
- };
36
-
37
- const sync = new Sp00kySync(
38
- {} as any, remote, {} as any, dataModule, {} as any, logger,
39
- );
40
- const enqueueDownEvent = vi.fn();
41
- (sync as any).enqueueDownEvent = enqueueDownEvent;
42
-
43
- const run = (hash: string) => (sync as any).cleanupQuery(hash) as Promise<void>;
44
- return { remote, dataModule, queryId, finalizeDeregister, enqueueDownEvent, run };
45
- }
46
-
47
- describe('cleanupQuery — releasing a possibly-shared view', () => {
48
- beforeEach(() => vi.clearAllMocks());
49
-
50
- it('does not touch the remote view at all; the TTL sweep reclaims it', async () => {
51
- // Eager release is deliberately disabled. It was inert for months (the
52
- // table granted no delete permission), and making it real turned every
53
- // best-effort guard misfire into a live delete of the row and all its
54
- // edges. TTL remains the only reclamation that has actually run.
55
- const { remote, run } = makeSync();
56
-
57
- await run('h1');
58
-
59
- expect(remote.query).not.toHaveBeenCalled();
60
- });
61
-
62
- it('never issues a bare DELETE', async () => {
63
- // Belt and braces: if the eager path is ever re-enabled, it must go through
64
- // the refcounted `fn::query::unsubscribe`, never a raw DELETE, which would
65
- // tear the view out from under other sessions sharing the row.
66
- const { remote, run } = makeSync();
67
-
68
- await run('h1');
69
-
70
- const sql = remote.query.mock.calls.map((c: any[]) => c[0]).join('\n');
71
- expect(sql).not.toMatch(/\bDELETE\b/i);
72
- });
73
-
74
- it('frees local state once released', async () => {
75
- const { finalizeDeregister, enqueueDownEvent, run } = makeSync();
76
-
77
- await run('h1');
78
-
79
- expect(finalizeDeregister).toHaveBeenCalledWith('h1');
80
- expect(enqueueDownEvent).not.toHaveBeenCalled();
81
- });
82
-
83
- it('does not touch the remote row if a subscriber reappeared before the release', async () => {
84
- const { remote, finalizeDeregister, run } = makeSync({
85
- hasSubscribers: [true],
86
- });
87
-
88
- await run('h1');
89
-
90
- expect(remote.query).not.toHaveBeenCalled();
91
- expect(finalizeDeregister).not.toHaveBeenCalled();
92
- });
93
-
94
- it('still frees local state when a subscriber reappears mid-cleanup', async () => {
95
- // With no remote round trip there is no window to lose a re-subscribe in,
96
- // so this collapses to the ordinary local free. The remote view survives
97
- // regardless (TTL owns it), which is precisely why the reappearing
98
- // subscriber is safe: a re-register finds the row still there.
99
- const { remote, finalizeDeregister, run } = makeSync({
100
- hasSubscribers: [false, true],
101
- });
102
-
103
- await run('h1');
104
-
105
- expect(remote.query).not.toHaveBeenCalled();
106
- expect(finalizeDeregister).toHaveBeenCalledWith('h1');
107
- });
108
-
109
- it('is tolerant of an already torn-down query', async () => {
110
- const { remote, dataModule, run } = makeSync();
111
- dataModule.getQueryByHash.mockReturnValue(undefined);
112
-
113
- await expect(run('gone')).resolves.toBeUndefined();
114
- expect(remote.query).not.toHaveBeenCalled();
115
- });
116
- });