@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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 (266) hide show
  1. package/AGENTS.md +84 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2080 -142
  4. package/dist/index.js +16009 -2480
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +373 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +477 -0
  18. package/dist/types.d.ts +1857 -38
  19. package/dist/worker-edges.js +480 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +32 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +80 -0
  36. package/src/boot/preload.saga.ts +40 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +195 -0
  40. package/src/client/bucket-handle.ts +352 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +434 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +167 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +512 -0
  62. package/src/live/index.ts +1059 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +228 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +476 -22
  72. package/src/modules/crdt/acceptance.ts +75 -0
  73. package/src/modules/crdt/bytes.ts +50 -0
  74. package/src/modules/crdt/crdt-field.ts +125 -0
  75. package/src/modules/crdt/crdt.test.ts +469 -0
  76. package/src/modules/crdt/hash.ts +103 -0
  77. package/src/modules/crdt/index.ts +757 -0
  78. package/src/modules/crdt/loro-loader.ts +25 -0
  79. package/src/modules/crdt/mirror.ts +37 -0
  80. package/src/modules/devtools/flags.ts +349 -0
  81. package/src/modules/devtools/index.ts +746 -95
  82. package/src/modules/devtools/mutations.test.ts +92 -0
  83. package/src/modules/devtools/mutations.ts +160 -0
  84. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  85. package/src/modules/devtools/state-shape.test.ts +263 -0
  86. package/src/modules/devtools/storage-info.test.ts +79 -0
  87. package/src/modules/devtools/storage-info.ts +168 -0
  88. package/src/modules/devtools/versions.test.ts +74 -0
  89. package/src/modules/devtools/versions.ts +110 -0
  90. package/src/modules/feature-flag/index.test.ts +298 -0
  91. package/src/modules/feature-flag/index.ts +316 -0
  92. package/src/modules/ref-tables.test.ts +93 -0
  93. package/src/modules/ref-tables.ts +91 -0
  94. package/src/modules/web-push/index.test.ts +824 -0
  95. package/src/modules/web-push/index.ts +1027 -0
  96. package/src/mutation/jobs.test.ts +25 -0
  97. package/src/mutation/jobs.ts +41 -0
  98. package/src/mutation/mutation-id.test.ts +37 -0
  99. package/src/mutation/mutation-id.ts +35 -0
  100. package/src/mutation/pending-writes.integration.test.ts +175 -0
  101. package/src/mutation/push.saga.test.ts +301 -0
  102. package/src/mutation/push.saga.ts +225 -0
  103. package/src/mutation/rows.test.ts +231 -0
  104. package/src/mutation/rows.ts +427 -0
  105. package/src/mutation/tray.saga.test.ts +63 -0
  106. package/src/mutation/tray.saga.ts +48 -0
  107. package/src/mutation/write.saga.test.ts +316 -0
  108. package/src/mutation/write.saga.ts +268 -0
  109. package/src/otel/index.ts +10 -7
  110. package/src/pure.ts +66 -0
  111. package/src/push/types.ts +174 -0
  112. package/src/query/anon-query-isolation.integration.test.ts +527 -0
  113. package/src/query/env.test.ts +23 -0
  114. package/src/query/env.ts +65 -0
  115. package/src/query/fetch.saga.test.ts +199 -0
  116. package/src/query/fetch.saga.ts +120 -0
  117. package/src/query/hash.test.ts +14 -0
  118. package/src/query/hash.ts +18 -0
  119. package/src/query/id-set-plan.test.ts +132 -0
  120. package/src/query/lifecycle.saga.test.ts +274 -0
  121. package/src/query/lifecycle.saga.ts +186 -0
  122. package/src/query/list-ref-live.integration.test.ts +203 -0
  123. package/src/query/materialize.saga.test.ts +115 -0
  124. package/src/query/materialize.saga.ts +84 -0
  125. package/src/query/materialize.test.ts +58 -0
  126. package/src/query/materialize.ts +57 -0
  127. package/src/query/membership.saga.test.ts +407 -0
  128. package/src/query/membership.saga.ts +282 -0
  129. package/src/query/membership.test.ts +118 -0
  130. package/src/query/membership.ts +201 -0
  131. package/src/query/register.saga.test.ts +311 -0
  132. package/src/query/register.saga.ts +235 -0
  133. package/src/query/render-set.test.ts +47 -0
  134. package/src/query/render-set.ts +71 -0
  135. package/src/query/sql.test.ts +118 -0
  136. package/src/query/sql.ts +157 -0
  137. package/src/query/window-query.test.ts +70 -0
  138. package/src/query/window-query.ts +208 -0
  139. package/src/services/blobs/blob-cache.test.ts +359 -0
  140. package/src/services/blobs/blob-cache.ts +603 -0
  141. package/src/services/blobs/blob-manifest.ts +227 -0
  142. package/src/services/blobs/blob-store.test.ts +77 -0
  143. package/src/services/blobs/blob-store.ts +359 -0
  144. package/src/services/blobs/blob.fixture.ts +90 -0
  145. package/src/services/blobs/index.ts +70 -0
  146. package/src/services/database/cache-engine.ts +209 -0
  147. package/src/services/database/connection-supervisor.test.ts +318 -0
  148. package/src/services/database/connection-supervisor.ts +422 -0
  149. package/src/services/database/database.query-timeout.test.ts +140 -0
  150. package/src/services/database/database.responses.test.ts +43 -0
  151. package/src/services/database/database.ts +156 -61
  152. package/src/services/database/engine-factory.ts +33 -0
  153. package/src/services/database/errors.ts +34 -0
  154. package/src/services/database/events/index.ts +2 -1
  155. package/src/services/database/index.ts +7 -0
  156. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  157. package/src/services/database/local-migrator.integration.test.ts +110 -0
  158. package/src/services/database/local-migrator.ts +61 -16
  159. package/src/services/database/local.test.ts +150 -0
  160. package/src/services/database/local.ts +508 -63
  161. package/src/services/database/plan-render.test.ts +159 -0
  162. package/src/services/database/plan-render.ts +108 -0
  163. package/src/services/database/relation-resolver.test.ts +484 -0
  164. package/src/services/database/relation-resolver.ts +0 -0
  165. package/src/services/database/remote.ts +285 -6
  166. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  167. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  168. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  169. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  170. package/src/services/database/sqlite-column-reset.ts +91 -0
  171. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  172. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  173. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  174. package/src/services/database/sqlite-lock-verify.ts +45 -0
  175. package/src/services/database/sqlite-open.test.ts +150 -0
  176. package/src/services/database/sqlite-open.ts +164 -0
  177. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  178. package/src/services/database/sqlite-plan-sql.ts +196 -0
  179. package/src/services/database/sqlite-projection.test.ts +121 -0
  180. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  181. package/src/services/database/sqlite-select.test.ts +261 -0
  182. package/src/services/database/sqlite-select.ts +131 -0
  183. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  184. package/src/services/database/sqlite-transport.ts +224 -0
  185. package/src/services/database/sqlite-worker.ts +437 -0
  186. package/src/services/database/surql-translate.ts +417 -0
  187. package/src/services/database/surreal-cache-engine.ts +163 -0
  188. package/src/services/logger/index.test.ts +132 -0
  189. package/src/services/logger/index.ts +189 -7
  190. package/src/services/persistence/localstorage.ts +2 -2
  191. package/src/services/persistence/resilient.ts +10 -3
  192. package/src/services/persistence/surrealdb.ts +4 -4
  193. package/src/services/stream-processor/index.ts +806 -67
  194. package/src/services/stream-processor/permissions.test.ts +47 -0
  195. package/src/services/stream-processor/permissions.ts +53 -0
  196. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  197. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  198. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  199. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  200. package/src/services/stream-processor/wasm-types.ts +58 -2
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -395
  211. package/src/state/client-state.ts +208 -0
  212. package/src/state/lifecycle.test.ts +141 -0
  213. package/src/state/lifecycle.ts +99 -0
  214. package/src/state/reducers.test.ts +306 -0
  215. package/src/state/reducers.ts +531 -0
  216. package/src/state/selectors.test.ts +188 -0
  217. package/src/state/selectors.ts +258 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +178 -0
  224. package/src/sync/live.saga.ts +107 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +633 -10
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +39 -0
  241. package/src/utils/error-classification.test.ts +54 -0
  242. package/src/utils/error-classification.ts +25 -0
  243. package/src/utils/index.ts +89 -12
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +64 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +29 -18
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -879
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -77
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -89
  262. package/src/modules/sync/queue/queue-up.ts +0 -223
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -421
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
3
+ import { deriveStatus, hasServerMembership, isAuthoritative, isForegroundFetching, LifecycleError, seedLifecycle, transition } from './lifecycle';
4
+
5
+ const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
6
+ phase,
7
+ remote: 'unregistered',
8
+ fetchDepth: 0,
9
+ backgroundDepth: 0,
10
+ notified: false,
11
+ ...over,
12
+ });
13
+
14
+ describe('transition table (phase axis)', () => {
15
+ it('seed', () => {
16
+ expect(transition(at('live'), { type: 'seed', resolvedBefore: true }).phase).toBe('cached');
17
+ expect(transition(at('live'), { type: 'seed', resolvedBefore: false }).phase).toBe('cold');
18
+ });
19
+ it('membership-applied moves every phase to live', () => {
20
+ for (const p of ['cold', 'cached', 'live'] as const) {
21
+ expect(transition(at(p), { type: 'membership-applied', present: true }).phase).toBe('live');
22
+ expect(transition(at(p), { type: 'membership-applied', present: false }).phase).toBe('live');
23
+ }
24
+ expect(transition(at('view-lost'), { type: 'membership-applied', present: true }).phase).toBe('live');
25
+ });
26
+ it('view-lost needs a present row to recover', () => {
27
+ expect(() => transition(at('view-lost'), { type: 'membership-applied', present: false })).toThrow(LifecycleError);
28
+ });
29
+ it('row-missing: cold stays cold, everything else goes view-lost', () => {
30
+ expect(transition(at('cold'), { type: 'row-missing' }).phase).toBe('cold');
31
+ expect(transition(at('cached'), { type: 'row-missing' }).phase).toBe('view-lost');
32
+ expect(transition(at('live'), { type: 'row-missing' }).phase).toBe('view-lost');
33
+ expect(transition(at('view-lost'), { type: 'row-missing' }).phase).toBe('view-lost');
34
+ });
35
+ it('bucket-switch reseeds and clears fetch depth', () => {
36
+ const l = transition(at('live', { fetchDepth: 3, notified: true }), { type: 'bucket-switch', resolvedBefore: true });
37
+ expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false });
38
+ });
39
+ });
40
+
41
+ describe('remote / fetch / notified axes', () => {
42
+ it('remote transitions', () => {
43
+ let l = at('cached');
44
+ l = transition(l, { type: 'remote-registering' });
45
+ expect(l.remote).toBe('registering');
46
+ l = transition(l, { type: 'remote-registered' });
47
+ expect(l.remote).toBe('registered');
48
+ l = transition(l, { type: 'notified' });
49
+ expect(l.notified).toBe(true);
50
+ l = transition(l, { type: 'remote-dropped' });
51
+ expect(l).toMatchObject({ remote: 'unregistered', notified: false });
52
+ l = transition(l, { type: 'remote-failed' });
53
+ expect(l.remote).toBe('failed');
54
+ });
55
+ it('fetch depth is a refcount that never goes negative', () => {
56
+ let l = at('live');
57
+ l = transition(l, { type: 'fetch-begin' });
58
+ l = transition(l, { type: 'fetch-begin' });
59
+ expect(deriveStatus(l)).toBe('fetching');
60
+ l = transition(l, { type: 'fetch-end' });
61
+ expect(deriveStatus(l)).toBe('fetching');
62
+ l = transition(l, { type: 'fetch-end' });
63
+ expect(deriveStatus(l)).toBe('idle');
64
+ const same = transition(l, { type: 'fetch-end' });
65
+ expect(same).toBe(l);
66
+ });
67
+ it('background cycles hold fetching but are not foreground', () => {
68
+ let l = at('cached');
69
+ l = transition(l, { type: 'fetch-begin', background: true });
70
+ expect(deriveStatus(l)).toBe('fetching');
71
+ expect(isForegroundFetching(l)).toBe(false);
72
+ l = transition(l, { type: 'fetch-begin' });
73
+ expect(isForegroundFetching(l)).toBe(true);
74
+ l = transition(l, { type: 'fetch-end' });
75
+ expect(l).toMatchObject({ fetchDepth: 1, backgroundDepth: 1 });
76
+ expect(isForegroundFetching(l)).toBe(false);
77
+ l = transition(l, { type: 'fetch-end', background: true });
78
+ expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
79
+ expect(deriveStatus(l)).toBe('idle');
80
+ });
81
+ it('an unbalanced end never leaves more background cycles than cycles', () => {
82
+ const l = transition(at('live', { fetchDepth: 1, backgroundDepth: 1 }), { type: 'fetch-end' });
83
+ expect(l).toMatchObject({ fetchDepth: 0, backgroundDepth: 0 });
84
+ });
85
+ it('notified is idempotent', () => {
86
+ const l = transition(at('live'), { type: 'notified' });
87
+ expect(transition(l, { type: 'notified' })).toBe(l);
88
+ });
89
+ });
90
+
91
+ describe('derived predicates', () => {
92
+ it('authority and server membership follow the phase', () => {
93
+ expect(isAuthoritative(at('cold'))).toBe(false);
94
+ expect(isAuthoritative(at('cached'))).toBe(true);
95
+ expect(hasServerMembership(at('cached'))).toBe(false);
96
+ expect(hasServerMembership(at('live'))).toBe(true);
97
+ expect(hasServerMembership(at('view-lost'))).toBe(true);
98
+ expect(seedLifecycle(true).phase).toBe('cached');
99
+ });
100
+ });
101
+
102
+ describe('invariants over random event sequences', () => {
103
+ const events: LifecycleEvent[] = [
104
+ { type: 'membership-applied', present: true },
105
+ { type: 'row-missing' },
106
+ { type: 'remote-registering' },
107
+ { type: 'remote-registered' },
108
+ { type: 'remote-dropped' },
109
+ { type: 'fetch-begin' },
110
+ { type: 'fetch-end' },
111
+ { type: 'fetch-begin', background: true },
112
+ { type: 'fetch-end', background: true },
113
+ { type: 'notified' },
114
+ { type: 'bucket-switch', resolvedBefore: false },
115
+ { type: 'seed', resolvedBefore: true },
116
+ ];
117
+ // Deterministic LCG so the sequence is reproducible.
118
+ let seed = 42;
119
+ const rand = () => (seed = (seed * 1664525 + 1013904223) % 2 ** 32) / 2 ** 32;
120
+
121
+ it('0 <= backgroundDepth <= fetchDepth, notified only after live-or-cached, view-lost never from cold', () => {
122
+ for (let run = 0; run < 200; run++) {
123
+ let l = seedLifecycle(rand() < 0.5);
124
+ for (let i = 0; i < 25; i++) {
125
+ const ev = events[Math.floor(rand() * events.length)];
126
+ const before = l;
127
+ try {
128
+ l = transition(l, ev);
129
+ } catch (err) {
130
+ expect(err).toBeInstanceOf(LifecycleError);
131
+ continue;
132
+ }
133
+ expect(l.fetchDepth).toBeGreaterThanOrEqual(0);
134
+ expect(l.backgroundDepth).toBeGreaterThanOrEqual(0);
135
+ expect(l.backgroundDepth).toBeLessThanOrEqual(l.fetchDepth);
136
+ if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
137
+ if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
138
+ }
139
+ }
140
+ });
141
+ });
@@ -0,0 +1,99 @@
1
+ import type { QueryStatus } from '../types';
2
+
3
+ /**
4
+ * The query lifecycle as one explicit machine.
5
+ *
6
+ * `phase` answers "where do this query's rows come from":
7
+ * - `cold` never resolved on this device: rows come from a local
8
+ * predicate scan (or the SSP local window), bindings show a loader.
9
+ * - `cached` a durable `_00_view` row was found: rows come from that id-set.
10
+ * - `live` a server membership set was accepted this session.
11
+ * - `view-lost` the server's `_00_query` row vanished while we held
12
+ * membership; rows are kept, a re-registration is under way.
13
+ *
14
+ * `remote` tracks the server-side registration, `fetchDepth` is the refcount
15
+ * behind `status: 'fetching'`, `notified` says whether subscribers received
16
+ * at least one materialization this registration.
17
+ *
18
+ * `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
19
+ * query already shows: a registration of a query that is not `cold`. It still
20
+ * holds `status: 'fetching'` (results are not yet confirmed), but it is not
21
+ * traffic the user waits on, so the activity indicator leaves it out.
22
+ */
23
+ export type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
24
+ export type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
25
+
26
+ export interface QueryLifecycle {
27
+ readonly phase: QueryPhase;
28
+ readonly remote: RemotePhase;
29
+ readonly fetchDepth: number;
30
+ readonly backgroundDepth: number;
31
+ readonly notified: boolean;
32
+ }
33
+
34
+ export type LifecycleEvent =
35
+ | { type: 'seed'; resolvedBefore: boolean }
36
+ | { type: 'membership-applied'; present: boolean }
37
+ | { type: 'row-missing' }
38
+ | { type: 'remote-registering' }
39
+ | { type: 'remote-registered' }
40
+ | { type: 'remote-failed' }
41
+ | { type: 'remote-dropped' }
42
+ | { type: 'fetch-begin'; background?: boolean }
43
+ | { type: 'fetch-end'; background?: boolean }
44
+ | { type: 'notified' }
45
+ | { type: 'bucket-switch'; resolvedBefore: boolean };
46
+
47
+ export class LifecycleError extends Error {
48
+ constructor(lifecycle: QueryLifecycle, event: LifecycleEvent) {
49
+ super(`Impossible lifecycle transition: ${lifecycle.phase}/${lifecycle.remote} + ${event.type}`);
50
+ this.name = 'LifecycleError';
51
+ }
52
+ }
53
+
54
+ export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
55
+ return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false };
56
+ }
57
+
58
+ /** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
59
+ export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycle {
60
+ switch (ev.type) {
61
+ case 'seed':
62
+ return seedLifecycle(ev.resolvedBefore);
63
+ case 'membership-applied':
64
+ if (l.phase === 'view-lost' && !ev.present) throw new LifecycleError(l, ev);
65
+ return { ...l, phase: 'live' };
66
+ case 'row-missing':
67
+ // A cold query holds nothing the server could have lost: the outcome
68
+ // is `ignored` upstream and the phase does not move.
69
+ return l.phase === 'cold' ? l : { ...l, phase: 'view-lost' };
70
+ case 'remote-registering':
71
+ return { ...l, remote: 'registering' };
72
+ case 'remote-registered':
73
+ return { ...l, remote: 'registered' };
74
+ case 'remote-failed':
75
+ return { ...l, remote: 'failed' };
76
+ case 'remote-dropped':
77
+ return { ...l, remote: 'unregistered', notified: false };
78
+ case 'fetch-begin':
79
+ return { ...l, fetchDepth: l.fetchDepth + 1, backgroundDepth: l.backgroundDepth + (ev.background ? 1 : 0) };
80
+ case 'fetch-end': {
81
+ if (l.fetchDepth === 0) return l;
82
+ const fetchDepth = l.fetchDepth - 1;
83
+ // Never more background cycles than cycles: an unbalanced end clamps.
84
+ const backgroundDepth = Math.min(fetchDepth, l.backgroundDepth - (ev.background && l.backgroundDepth > 0 ? 1 : 0));
85
+ return { ...l, fetchDepth, backgroundDepth };
86
+ }
87
+ case 'notified':
88
+ return l.notified ? l : { ...l, notified: true };
89
+ case 'bucket-switch':
90
+ return { ...seedLifecycle(ev.resolvedBefore), fetchDepth: 0 };
91
+ }
92
+ }
93
+
94
+ export const isAuthoritative = (l: QueryLifecycle): boolean => l.phase !== 'cold';
95
+ export const hasServerMembership = (l: QueryLifecycle): boolean =>
96
+ l.phase === 'live' || l.phase === 'view-lost';
97
+ export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
98
+ /** A fetch cycle the user is waiting on: rows to download, or a first registration. */
99
+ export const isForegroundFetching = (l: QueryLifecycle): boolean => l.fetchDepth > l.backgroundDepth;
@@ -0,0 +1,306 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import * as R from './reducers';
3
+ import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
4
+ import { emptyState } from './client-state';
5
+
6
+ const e = (hash: string, over: Parameters<typeof buildEntry>[0] = {}) => buildEntry({ ...over, def: { hash, ...over.def } });
7
+
8
+ describe('query reducers', () => {
9
+ it('putQuery adds and dirties; removeQuery clears dirt', () => {
10
+ let s = R.putQuery(e('a'))(emptyState({ tabId: 't' }));
11
+ expect(s.queries.has('a')).toBe(true);
12
+ expect(s.dirty.has('a')).toBe(true);
13
+ s = R.markMembershipDirty(['a'])(s);
14
+ s = R.removeQuery('a')(s);
15
+ expect(s.queries.size).toBe(0);
16
+ expect(s.dirty.size).toBe(0);
17
+ expect(s.membershipDirty.size).toBe(0);
18
+ expect(R.removeQuery('zzz')(s)).toBe(s);
19
+ });
20
+
21
+ it('reducers on an unknown hash are identity', () => {
22
+ const s = buildState([e('a')]);
23
+ expect(R.applyLifecycle('x', { type: 'notified' })(s)).toBe(s);
24
+ expect(R.setLocalArray('x', [])(s)).toBe(s);
25
+ expect(R.commitMembership('x', [['thing:1', 1]], true)(s)).toBe(s);
26
+ expect(R.subscribe('x')(s)).toBe(s);
27
+ expect(R.setRecords('x', [], true, 1)(s)).toBe(s);
28
+ });
29
+
30
+ it('applyLifecycle / setServerState', () => {
31
+ const s0 = buildState([e('a')]);
32
+ const s1 = R.applyLifecycle('a', { type: 'remote-registering' })(s0);
33
+ expect(s1.queries.get('a')!.lifecycle.remote).toBe('registering');
34
+ const s2 = R.setServerState('a', 'ready')(s1);
35
+ expect(s2.queries.get('a')!.serverState).toBe('ready');
36
+ expect(R.setServerState('a', 'ready')(s2)).toBe(s2);
37
+ });
38
+
39
+ it('commitMembership sets the array, flips live, dirties, and releases acked items it names', () => {
40
+ const s0 = buildState(
41
+ [e('a', { lifecycle: { phase: 'cached' } })],
42
+ R.outboxReplace([
43
+ buildOutboxItem({ id: 'm1', recordId: 'thing:1', status: 'acked', ackedAt: 1 }),
44
+ buildOutboxItem({ id: 'm2', recordId: 'thing:2', status: 'acked', ackedAt: 1 }),
45
+ buildOutboxItem({ id: 'm3', recordId: 'thing:1', status: 'pending' }),
46
+ ])
47
+ );
48
+ const s1 = R.commitMembership('a', [['thing:1', 2]], true)({ ...s0, dirty: new Set() });
49
+ const entry = s1.queries.get('a')!;
50
+ expect(entry.remoteArray).toEqual([['thing:1', 2]]);
51
+ expect(entry.lifecycle.phase).toBe('live');
52
+ expect(s1.dirty.has('a')).toBe(true);
53
+ expect(s1.outbox.map((i) => i.id)).toEqual(['m2', 'm3']);
54
+ });
55
+
56
+ it('setLocalArray dirties; setSubqueryRemoteArray does not', () => {
57
+ const s0 = buildState([e('a')]);
58
+ const s1 = R.setLocalArray('a', [['thing:1', 1]])(s0);
59
+ expect(s1.dirty.has('a')).toBe(true);
60
+ const s2 = R.setSubqueryRemoteArray('a', [['child:1', 1]])({ ...s1, dirty: new Set() });
61
+ expect(s2.dirty.size).toBe(0);
62
+ expect(s2.queries.get('a')!.subqueryRemoteArray).toEqual([['child:1', 1]]);
63
+ });
64
+
65
+ it('setRecords clears dirt, counts changes, samples timing, marks notified', () => {
66
+ const s0 = R.markDirty(['a'])(buildState([e('a')]));
67
+ const rows = [{ id: 'thing:1' }];
68
+ const s1 = R.setRecords('a', rows, true, 12)(s0);
69
+ const en = s1.queries.get('a')!;
70
+ expect(en.records).toBe(rows);
71
+ expect(en.telemetry.updateCount).toBe(1);
72
+ expect(en.telemetry.phaseSamples.localFetch).toEqual([12]);
73
+ expect(en.telemetry.phaseLast.localFetch).toBe(12);
74
+ expect(en.lifecycle.notified).toBe(true);
75
+ expect(s1.dirty.has('a')).toBe(false);
76
+ const s2 = R.setRecords('a', [{ id: 'other' }], false, null)(s1);
77
+ expect(s2.queries.get('a')!.records).toBe(rows);
78
+ expect(s2.queries.get('a')!.telemetry.updateCount).toBe(1);
79
+ expect(s2.queries.get('a')!.telemetry.phaseSamples.localFetch).toEqual([12]);
80
+ });
81
+
82
+ it('sample windows are capped', () => {
83
+ let s = buildState([e('a')]);
84
+ for (let i = 0; i < 105; i++) s = R.recordPhase('a', 'localFetch', i)(s);
85
+ const t = s.queries.get('a')!.telemetry;
86
+ expect(t.phaseSamples.localFetch).toHaveLength(100);
87
+ expect(t.phaseLast.localFetch).toBe(104);
88
+ for (let i = 0; i < 105; i++) s = R.setRecords('a', [], false, i)(s);
89
+ expect(s.queries.get('a')!.telemetry.phaseSamples.localFetch).toHaveLength(100);
90
+ for (let i = 0; i < 105; i++) s = R.recordIngest('a', i)(s);
91
+ expect(s.queries.get('a')!.telemetry.materializationSamples).toHaveLength(100);
92
+ expect(s.queries.get('a')!.telemetry.lastIngestLatencyMs).toBe(104);
93
+ });
94
+
95
+ it('telemetry helpers', () => {
96
+ let s = buildState([e('a')]);
97
+ s = R.stampUpdated('a', 5)(s);
98
+ s = R.recordError('a')(s);
99
+ s = R.setRegistrationTimings('a', { parseMs: 1, planMs: 2, snapshotMs: 3, wallMs: 4 })(s);
100
+ s = R.bumpRegisterAttempts('a')(s);
101
+ s = R.bumpRegisterAttempts('a')(s);
102
+ s = R.stampHeartbeat(['a', 'missing'], 9)(s);
103
+ s = R.stampPolled(['a'], 11)(s);
104
+ const en = s.queries.get('a')!;
105
+ expect(en.telemetry.lastUpdatedAt).toBe(5);
106
+ expect(en.telemetry.errorCount).toBe(1);
107
+ expect(en.telemetry.registrationTimings.wallMs).toBe(4);
108
+ expect(en.registerAttempts).toBe(2);
109
+ expect(en.lastHeartbeatAt).toBe(9);
110
+ expect(en.lastPolledAt).toBe(11);
111
+ const reset = R.resetRegisterAttempts('a')(s);
112
+ expect(reset.queries.get('a')!.registerAttempts).toBe(0);
113
+ expect(R.resetRegisterAttempts('a')(reset)).toBe(reset);
114
+ const lost = R.setViewLost('a', 2, 900)(reset);
115
+ expect(lost.queries.get('a')).toMatchObject({ viewLostCount: 2, viewLostRetryAt: 900 });
116
+ expect(R.setViewLost('a', 2, 900)(lost)).toBe(lost);
117
+ });
118
+
119
+ it('subscribe / unsubscribe track the eviction clock', () => {
120
+ let s = buildState([e('a')]);
121
+ s = R.subscribe('a')(s);
122
+ s = R.subscribe('a')(s);
123
+ expect(s.queries.get('a')!.subscribers).toBe(2);
124
+ s = R.unsubscribe('a', 100)(s);
125
+ expect(s.queries.get('a')!.lastSubscriberLeftAt).toBeNull();
126
+ s = R.unsubscribe('a', 200)(s);
127
+ expect(s.queries.get('a')!.lastSubscriberLeftAt).toBe(200);
128
+ s = R.unsubscribe('a', 300)(s);
129
+ expect(s.queries.get('a')!.subscribers).toBe(0);
130
+ s = R.subscribe('a')(s);
131
+ expect(s.queries.get('a')!.lastSubscriberLeftAt).toBeNull();
132
+ });
133
+ });
134
+
135
+ describe('registering / reread bookkeeping', () => {
136
+ it('tracks in-flight local registrations and reread attempts; removeQuery clears rereads', () => {
137
+ let s = buildState([e('a')]);
138
+ s = R.beginRegistering('x')(s);
139
+ expect(s.registering.has('x')).toBe(true);
140
+ s = R.endRegistering('x')(s);
141
+ expect(s.registering.size).toBe(0);
142
+ expect(R.endRegistering('x')(s)).toBe(s);
143
+ s = R.setMembershipReread('a', 1)(s);
144
+ expect(s.membershipReread.get('a')).toBe(1);
145
+ expect(R.setMembershipReread('zzz', null)(s)).toBe(s);
146
+ const cleared = R.removeQuery('a')(s);
147
+ expect(cleared.membershipReread.size).toBe(0);
148
+ s = R.setMembershipReread('a', null)(s);
149
+ expect(s.membershipReread.size).toBe(0);
150
+ });
151
+ });
152
+
153
+ describe('dirt reducers', () => {
154
+ it('markDirty / clearDirty / markTableDirty', () => {
155
+ const s0 = buildState([e('a', { def: { tableName: 't1' } }), e('b', { def: { tableName: 't2' } })]);
156
+ expect(R.markDirty([])(s0)).toBe(s0);
157
+ const s1 = R.markTableDirty('t1')(s0);
158
+ expect([...s1.dirty]).toEqual(['a']);
159
+ const s2 = R.clearDirty('a')(s1);
160
+ expect(s2.dirty.size).toBe(0);
161
+ expect(R.clearDirty('a')(s2)).toBe(s2);
162
+ });
163
+ it('membership dirt only for known hashes', () => {
164
+ const s0 = buildState([e('a')]);
165
+ expect(R.markMembershipDirty(['nope'])(s0)).toBe(s0);
166
+ const s1 = R.markMembershipDirty(['a', 'nope'])(s0);
167
+ expect([...s1.membershipDirty]).toEqual(['a']);
168
+ expect(R.clearMembershipDirty(['a'])(s1).membershipDirty.size).toBe(0);
169
+ });
170
+ });
171
+
172
+ describe('versions', () => {
173
+ it('setVersions dirties queries naming the id, ignores unchanged, deleteVersions removes', () => {
174
+ const s0 = buildState([
175
+ e('a', { remoteArray: [['thing:1', 1]] }),
176
+ e('b', { localArray: [['thing:2', 1]] }),
177
+ e('c'),
178
+ // A related row (a thread's author): a new body must re-render the list.
179
+ e('d', { subqueryRemoteArray: [['user:1', 1]] }),
180
+ ]);
181
+ expect(R.setVersions([])(s0)).toBe(s0);
182
+ const s1 = R.setVersions([['thing:1', 1], ['thing:2', 1], ['thing:9', 1], ['user:1', 2]])(s0);
183
+ expect([...s1.dirty].sort()).toEqual(['a', 'b', 'd']);
184
+ expect(R.setVersions([['thing:1', 1]])(s1)).toBe(s1);
185
+ const s2 = R.deleteVersions(['thing:9', 'nope'])(s1);
186
+ expect(s2.versions.has('thing:9')).toBe(false);
187
+ expect(R.deleteVersions(['nope'])(s2)).toBe(s2);
188
+ });
189
+ });
190
+
191
+ describe('outbox', () => {
192
+ it('push / ack / bump / remove / replace dirty the table', () => {
193
+ const s0 = buildState([e('a', { def: { tableName: 'thing' } })]);
194
+ const s1 = R.outboxPush(buildOutboxItem({ id: 'm1' }))(s0);
195
+ expect(s1.dirty.has('a')).toBe(true);
196
+ const s2 = R.outboxAck('m1', 50)({ ...s1, dirty: new Set() });
197
+ expect(s2.outbox[0]).toMatchObject({ status: 'acked', ackedAt: 50 });
198
+ expect(s2.dirty.size).toBe(0);
199
+ expect(R.outboxAck('nope', 1)(s2)).toBe(s2);
200
+ const s3 = R.outboxBumpAttempts('m1')(s2);
201
+ expect(s3.outbox[0].attempts).toBe(1);
202
+ expect(R.outboxBumpAttempts('nope')(s3)).toBe(s3);
203
+ const s4 = R.outboxRemove('m1')(s3);
204
+ expect(s4.outbox).toEqual([]);
205
+ expect(s4.dirty.has('a')).toBe(true);
206
+ expect(R.outboxRemove('m1')(s4)).toBe(s4);
207
+ const s5 = R.outboxReplace([buildOutboxItem({ id: 'x', table: 'thing' })])({ ...s4, dirty: new Set() });
208
+ expect(s5.outbox).toHaveLength(1);
209
+ expect(s5.dirty.has('a')).toBe(true);
210
+ });
211
+ it('outboxPruneAcked drops only expired acked items', () => {
212
+ const s0 = buildState(
213
+ [e('a', { def: { tableName: 'thing' } })],
214
+ R.outboxReplace([
215
+ buildOutboxItem({ id: 'old', status: 'acked', ackedAt: 0 }),
216
+ buildOutboxItem({ id: 'fresh', status: 'acked', ackedAt: 90 }),
217
+ buildOutboxItem({ id: 'pending' }),
218
+ ])
219
+ );
220
+ const s1 = R.outboxPruneAcked(100, 50)({ ...s0, dirty: new Set() });
221
+ expect(s1.outbox.map((i) => i.id)).toEqual(['fresh', 'pending']);
222
+ expect(s1.dirty.has('a')).toBe(true);
223
+ expect(R.outboxPruneAcked(100, 50)(s1)).toBe(s1);
224
+ });
225
+ it('pending writes merge per key and clear', () => {
226
+ const w = { key: 'k', table: 'thing', recordId: 'thing:1', data: { a: 1 }, before: { a: 0 }, firstAt: 1, mirrorId: null };
227
+ let s = R.mergePendingWrite(w)(buildState());
228
+ s = R.mergePendingWrite({ ...w, data: { b: 2 }, before: null, firstAt: 9, mirrorId: '_00_pending_writes:m' })(s);
229
+ expect(s.pendingWrites.get('k')).toEqual({ ...w, data: { a: 1, b: 2 }, mirrorId: '_00_pending_writes:m' });
230
+ s = R.mergePendingWrite({ ...w, mirrorId: '_00_pending_writes:other' })(s);
231
+ expect(s.pendingWrites.get('k')!.mirrorId).toBe('_00_pending_writes:m');
232
+ s = R.clearPendingWrite('k')(s);
233
+ expect(s.pendingWrites.size).toBe(0);
234
+ expect(R.clearPendingWrite('k')(s)).toBe(s);
235
+ });
236
+ it('setFailedCount', () => {
237
+ const s0 = buildState();
238
+ const s1 = R.setFailedCount(2)(s0);
239
+ expect(s1.failedCount).toBe(2);
240
+ expect(R.setFailedCount(2)(s1)).toBe(s1);
241
+ });
242
+ });
243
+
244
+ describe('bucket switch reducers', () => {
245
+ it('rebindQuery keeps the hash, swaps the id and sync state, dirties; clearBucketState wipes per-bucket slices', () => {
246
+ const s0 = buildState(
247
+ [e('a', { lifecycle: { phase: 'live', remote: 'registered', notified: true }, remoteArray: [['t:1', 1]], records: [{ id: 't:1' }], serverState: 'ready', registerAttempts: 2 })],
248
+ R.setVersions([['t:1', 1]]),
249
+ R.outboxReplace([buildOutboxItem()])
250
+ );
251
+ const id = { table: '_00_query', id: 'new' } as any;
252
+ const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, backgroundDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
253
+ const en = s1.queries.get('a')!;
254
+ expect(en.def.id).toBe(id);
255
+ expect(en.lifecycle.phase).toBe('cached');
256
+ expect(en.remoteArray).toEqual([['t:2', 1]]);
257
+ expect(en.records).toEqual([]);
258
+ expect(en.serverState).toBeNull();
259
+ expect(en.registerAttempts).toBe(0);
260
+ expect(s1.dirty.has('a')).toBe(true);
261
+ expect(R.rebindQuery('zz', { id, lifecycle: en.lifecycle, remoteArray: [], localArray: [] })(s1)).toBe(s1);
262
+ const s2 = R.clearBucketState()(s1);
263
+ expect(s2.versions.size).toBe(0);
264
+ expect(s2.outbox).toEqual([]);
265
+ expect(s2.dirty.size).toBe(0);
266
+ expect(s2.primed).toBe(false);
267
+ });
268
+ });
269
+
270
+ describe('identity / connection / compose', () => {
271
+ it('sets fields and short-circuits on no change', () => {
272
+ const s0 = buildState();
273
+ const s1 = R.setIdentity({ sessionId: 's', userId: 'u', bucketId: 'b' })(s0);
274
+ expect(s1).toMatchObject({ sessionId: 's', userId: 'u', bucketId: 'b' });
275
+ const s2 = R.setTabRole('leader')(s1);
276
+ expect(R.setTabRole('leader')(s2)).toBe(s2);
277
+ const s3 = R.setConnection('connected')(s2);
278
+ expect(s3.sync.health.connection).toBe('connected');
279
+ expect(R.setConnection('connected')(s3)).toBe(s3);
280
+ const s4 = R.setHealth({ ...s3.sync.health, status: 'degraded' })(s3);
281
+ expect(s4.sync.health.status).toBe('degraded');
282
+ const s5 = R.patchSync({ pollIdleStreak: 4 })(s4);
283
+ expect(s5.sync.pollIdleStreak).toBe(4);
284
+ const s6 = R.compose(R.setFailedCount(1), R.setFailedCount(3))(s5);
285
+ expect(s6.failedCount).toBe(3);
286
+ });
287
+ });
288
+
289
+ describe('durable view index', () => {
290
+ it('commit and children keep a view key in step with its `_00_view` row; cold children are not indexed', () => {
291
+ let s = buildState([e('a', { def: { viewKey: 'va' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })]);
292
+ s = R.setSubqueryRemoteArray('a', [['c:1', 1], ['c:2', 1]])(s);
293
+ expect(s.views.size).toBe(0);
294
+ s = R.commitMembership('a', [['t:1', 1], ['c:1', 1]], true)(s);
295
+ expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:2']);
296
+ s = R.setSubqueryRemoteArray('a', [['c:3', 1]])(s);
297
+ expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:3']);
298
+ expect(R.setSubqueryRemoteArray('zz', [['c:9', 1]])(s)).toBe(s);
299
+ expect(R.clearBucketState()(s).views.size).toBe(0);
300
+ });
301
+ it('a reload takes the store, except entries of queries in state', () => {
302
+ const s = R.compose(R.commitMembership('a', [['t:new', 1]], true))(buildState([e('a', { def: { viewKey: 'va' } }), e('b', { def: { viewKey: 'vb' } })]));
303
+ const reloaded = R.reloadViews(new Map([['va', ['t:old']], ['vb', ['t:b']], ['vc', ['t:c']]]))(s);
304
+ expect(reloaded.views).toEqual(new Map([['va', ['t:new']], ['vb', ['t:b']], ['vc', ['t:c']]]));
305
+ });
306
+ });