@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.301

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 (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14939 -2432
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -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 +472 -0
  18. package/dist/types.d.ts +1765 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +82 -0
  30. package/src/boot/auth-flip.saga.ts +62 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -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 +51 -0
  36. package/src/boot/preload.saga.ts +31 -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 +117 -0
  40. package/src/client/bucket-handle.ts +227 -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.ts +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +57 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +746 -95
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +75 -9
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +2 -1
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { LifecycleEvent, QueryLifecycle, QueryPhase } from './lifecycle';
3
+ import { deriveStatus, hasServerMembership, isAuthoritative, LifecycleError, seedLifecycle, transition } from './lifecycle';
4
+
5
+ const at = (phase: QueryPhase, over: Partial<QueryLifecycle> = {}): QueryLifecycle => ({
6
+ phase,
7
+ remote: 'unregistered',
8
+ fetchDepth: 0,
9
+ notified: false,
10
+ ...over,
11
+ });
12
+
13
+ describe('transition table (phase axis)', () => {
14
+ it('seed', () => {
15
+ expect(transition(at('live'), { type: 'seed', resolvedBefore: true }).phase).toBe('cached');
16
+ expect(transition(at('live'), { type: 'seed', resolvedBefore: false }).phase).toBe('cold');
17
+ });
18
+ it('membership-applied moves every phase to live', () => {
19
+ for (const p of ['cold', 'cached', 'live'] as const) {
20
+ expect(transition(at(p), { type: 'membership-applied', present: true }).phase).toBe('live');
21
+ expect(transition(at(p), { type: 'membership-applied', present: false }).phase).toBe('live');
22
+ }
23
+ expect(transition(at('view-lost'), { type: 'membership-applied', present: true }).phase).toBe('live');
24
+ });
25
+ it('view-lost needs a present row to recover', () => {
26
+ expect(() => transition(at('view-lost'), { type: 'membership-applied', present: false })).toThrow(LifecycleError);
27
+ });
28
+ it('row-missing: cold stays cold, everything else goes view-lost', () => {
29
+ expect(transition(at('cold'), { type: 'row-missing' }).phase).toBe('cold');
30
+ expect(transition(at('cached'), { type: 'row-missing' }).phase).toBe('view-lost');
31
+ expect(transition(at('live'), { type: 'row-missing' }).phase).toBe('view-lost');
32
+ expect(transition(at('view-lost'), { type: 'row-missing' }).phase).toBe('view-lost');
33
+ });
34
+ it('bucket-switch reseeds and clears fetch depth', () => {
35
+ const l = transition(at('live', { fetchDepth: 3, notified: true }), { type: 'bucket-switch', resolvedBefore: true });
36
+ expect(l).toEqual({ phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false });
37
+ });
38
+ });
39
+
40
+ describe('remote / fetch / notified axes', () => {
41
+ it('remote transitions', () => {
42
+ let l = at('cached');
43
+ l = transition(l, { type: 'remote-registering' });
44
+ expect(l.remote).toBe('registering');
45
+ l = transition(l, { type: 'remote-registered' });
46
+ expect(l.remote).toBe('registered');
47
+ l = transition(l, { type: 'notified' });
48
+ expect(l.notified).toBe(true);
49
+ l = transition(l, { type: 'remote-dropped' });
50
+ expect(l).toMatchObject({ remote: 'unregistered', notified: false });
51
+ l = transition(l, { type: 'remote-failed' });
52
+ expect(l.remote).toBe('failed');
53
+ });
54
+ it('fetch depth is a refcount that never goes negative', () => {
55
+ let l = at('live');
56
+ l = transition(l, { type: 'fetch-begin' });
57
+ l = transition(l, { type: 'fetch-begin' });
58
+ expect(deriveStatus(l)).toBe('fetching');
59
+ l = transition(l, { type: 'fetch-end' });
60
+ expect(deriveStatus(l)).toBe('fetching');
61
+ l = transition(l, { type: 'fetch-end' });
62
+ expect(deriveStatus(l)).toBe('idle');
63
+ const same = transition(l, { type: 'fetch-end' });
64
+ expect(same).toBe(l);
65
+ });
66
+ it('notified is idempotent', () => {
67
+ const l = transition(at('live'), { type: 'notified' });
68
+ expect(transition(l, { type: 'notified' })).toBe(l);
69
+ });
70
+ });
71
+
72
+ describe('derived predicates', () => {
73
+ it('authority and server membership follow the phase', () => {
74
+ expect(isAuthoritative(at('cold'))).toBe(false);
75
+ expect(isAuthoritative(at('cached'))).toBe(true);
76
+ expect(hasServerMembership(at('cached'))).toBe(false);
77
+ expect(hasServerMembership(at('live'))).toBe(true);
78
+ expect(hasServerMembership(at('view-lost'))).toBe(true);
79
+ expect(seedLifecycle(true).phase).toBe('cached');
80
+ });
81
+ });
82
+
83
+ describe('invariants over random event sequences', () => {
84
+ const events: LifecycleEvent[] = [
85
+ { type: 'membership-applied', present: true },
86
+ { type: 'row-missing' },
87
+ { type: 'remote-registering' },
88
+ { type: 'remote-registered' },
89
+ { type: 'remote-dropped' },
90
+ { type: 'fetch-begin' },
91
+ { type: 'fetch-end' },
92
+ { type: 'notified' },
93
+ { type: 'bucket-switch', resolvedBefore: false },
94
+ { type: 'seed', resolvedBefore: true },
95
+ ];
96
+ // Deterministic LCG so the sequence is reproducible.
97
+ let seed = 42;
98
+ const rand = () => (seed = (seed * 1664525 + 1013904223) % 2 ** 32) / 2 ** 32;
99
+
100
+ it('fetchDepth >= 0, notified only after live-or-cached, view-lost never from cold', () => {
101
+ for (let run = 0; run < 200; run++) {
102
+ let l = seedLifecycle(rand() < 0.5);
103
+ for (let i = 0; i < 25; i++) {
104
+ const ev = events[Math.floor(rand() * events.length)];
105
+ const before = l;
106
+ try {
107
+ l = transition(l, ev);
108
+ } catch (err) {
109
+ expect(err).toBeInstanceOf(LifecycleError);
110
+ continue;
111
+ }
112
+ expect(l.fetchDepth).toBeGreaterThanOrEqual(0);
113
+ if (ev.type === 'row-missing' && before.phase === 'cold') expect(l.phase).toBe('cold');
114
+ if (l.phase === 'view-lost') expect(before.phase).not.toBe('cold');
115
+ }
116
+ }
117
+ });
118
+ });
@@ -0,0 +1,86 @@
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
+ export type QueryPhase = 'cold' | 'cached' | 'live' | 'view-lost';
19
+ export type RemotePhase = 'unregistered' | 'registering' | 'registered' | 'failed';
20
+
21
+ export interface QueryLifecycle {
22
+ readonly phase: QueryPhase;
23
+ readonly remote: RemotePhase;
24
+ readonly fetchDepth: number;
25
+ readonly notified: boolean;
26
+ }
27
+
28
+ export type LifecycleEvent =
29
+ | { type: 'seed'; resolvedBefore: boolean }
30
+ | { type: 'membership-applied'; present: boolean }
31
+ | { type: 'row-missing' }
32
+ | { type: 'remote-registering' }
33
+ | { type: 'remote-registered' }
34
+ | { type: 'remote-failed' }
35
+ | { type: 'remote-dropped' }
36
+ | { type: 'fetch-begin' }
37
+ | { type: 'fetch-end' }
38
+ | { type: 'notified' }
39
+ | { type: 'bucket-switch'; resolvedBefore: boolean };
40
+
41
+ export class LifecycleError extends Error {
42
+ constructor(lifecycle: QueryLifecycle, event: LifecycleEvent) {
43
+ super(`Impossible lifecycle transition: ${lifecycle.phase}/${lifecycle.remote} + ${event.type}`);
44
+ this.name = 'LifecycleError';
45
+ }
46
+ }
47
+
48
+ export function seedLifecycle(resolvedBefore: boolean): QueryLifecycle {
49
+ return { phase: resolvedBefore ? 'cached' : 'cold', remote: 'unregistered', fetchDepth: 0, notified: false };
50
+ }
51
+
52
+ /** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
53
+ export function transition(l: QueryLifecycle, ev: LifecycleEvent): QueryLifecycle {
54
+ switch (ev.type) {
55
+ case 'seed':
56
+ return seedLifecycle(ev.resolvedBefore);
57
+ case 'membership-applied':
58
+ if (l.phase === 'view-lost' && !ev.present) throw new LifecycleError(l, ev);
59
+ return { ...l, phase: 'live' };
60
+ case 'row-missing':
61
+ // A cold query holds nothing the server could have lost: the outcome
62
+ // is `ignored` upstream and the phase does not move.
63
+ return l.phase === 'cold' ? l : { ...l, phase: 'view-lost' };
64
+ case 'remote-registering':
65
+ return { ...l, remote: 'registering' };
66
+ case 'remote-registered':
67
+ return { ...l, remote: 'registered' };
68
+ case 'remote-failed':
69
+ return { ...l, remote: 'failed' };
70
+ case 'remote-dropped':
71
+ return { ...l, remote: 'unregistered', notified: false };
72
+ case 'fetch-begin':
73
+ return { ...l, fetchDepth: l.fetchDepth + 1 };
74
+ case 'fetch-end':
75
+ return l.fetchDepth === 0 ? l : { ...l, fetchDepth: l.fetchDepth - 1 };
76
+ case 'notified':
77
+ return l.notified ? l : { ...l, notified: true };
78
+ case 'bucket-switch':
79
+ return { ...seedLifecycle(ev.resolvedBefore), fetchDepth: 0 };
80
+ }
81
+ }
82
+
83
+ export const isAuthoritative = (l: QueryLifecycle): boolean => l.phase !== 'cold';
84
+ export const hasServerMembership = (l: QueryLifecycle): boolean =>
85
+ l.phase === 'live' || l.phase === 'view-lost';
86
+ export const deriveStatus = (l: QueryLifecycle): QueryStatus => (l.fetchDepth > 0 ? 'fetching' : 'idle');
@@ -0,0 +1,304 @@
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
+ ]);
179
+ expect(R.setVersions([])(s0)).toBe(s0);
180
+ const s1 = R.setVersions([['thing:1', 1], ['thing:2', 1], ['thing:9', 1]])(s0);
181
+ expect([...s1.dirty].sort()).toEqual(['a', 'b']);
182
+ expect(R.setVersions([['thing:1', 1]])(s1)).toBe(s1);
183
+ const s2 = R.deleteVersions(['thing:9', 'nope'])(s1);
184
+ expect(s2.versions.has('thing:9')).toBe(false);
185
+ expect(R.deleteVersions(['nope'])(s2)).toBe(s2);
186
+ });
187
+ });
188
+
189
+ describe('outbox', () => {
190
+ it('push / ack / bump / remove / replace dirty the table', () => {
191
+ const s0 = buildState([e('a', { def: { tableName: 'thing' } })]);
192
+ const s1 = R.outboxPush(buildOutboxItem({ id: 'm1' }))(s0);
193
+ expect(s1.dirty.has('a')).toBe(true);
194
+ const s2 = R.outboxAck('m1', 50)({ ...s1, dirty: new Set() });
195
+ expect(s2.outbox[0]).toMatchObject({ status: 'acked', ackedAt: 50 });
196
+ expect(s2.dirty.size).toBe(0);
197
+ expect(R.outboxAck('nope', 1)(s2)).toBe(s2);
198
+ const s3 = R.outboxBumpAttempts('m1')(s2);
199
+ expect(s3.outbox[0].attempts).toBe(1);
200
+ expect(R.outboxBumpAttempts('nope')(s3)).toBe(s3);
201
+ const s4 = R.outboxRemove('m1')(s3);
202
+ expect(s4.outbox).toEqual([]);
203
+ expect(s4.dirty.has('a')).toBe(true);
204
+ expect(R.outboxRemove('m1')(s4)).toBe(s4);
205
+ const s5 = R.outboxReplace([buildOutboxItem({ id: 'x', table: 'thing' })])({ ...s4, dirty: new Set() });
206
+ expect(s5.outbox).toHaveLength(1);
207
+ expect(s5.dirty.has('a')).toBe(true);
208
+ });
209
+ it('outboxPruneAcked drops only expired acked items', () => {
210
+ const s0 = buildState(
211
+ [e('a', { def: { tableName: 'thing' } })],
212
+ R.outboxReplace([
213
+ buildOutboxItem({ id: 'old', status: 'acked', ackedAt: 0 }),
214
+ buildOutboxItem({ id: 'fresh', status: 'acked', ackedAt: 90 }),
215
+ buildOutboxItem({ id: 'pending' }),
216
+ ])
217
+ );
218
+ const s1 = R.outboxPruneAcked(100, 50)({ ...s0, dirty: new Set() });
219
+ expect(s1.outbox.map((i) => i.id)).toEqual(['fresh', 'pending']);
220
+ expect(s1.dirty.has('a')).toBe(true);
221
+ expect(R.outboxPruneAcked(100, 50)(s1)).toBe(s1);
222
+ });
223
+ it('pending writes merge per key and clear', () => {
224
+ const w = { key: 'k', table: 'thing', recordId: 'thing:1', data: { a: 1 }, before: { a: 0 }, firstAt: 1, mirrorId: null };
225
+ let s = R.mergePendingWrite(w)(buildState());
226
+ s = R.mergePendingWrite({ ...w, data: { b: 2 }, before: null, firstAt: 9, mirrorId: '_00_pending_writes:m' })(s);
227
+ expect(s.pendingWrites.get('k')).toEqual({ ...w, data: { a: 1, b: 2 }, mirrorId: '_00_pending_writes:m' });
228
+ s = R.mergePendingWrite({ ...w, mirrorId: '_00_pending_writes:other' })(s);
229
+ expect(s.pendingWrites.get('k')!.mirrorId).toBe('_00_pending_writes:m');
230
+ s = R.clearPendingWrite('k')(s);
231
+ expect(s.pendingWrites.size).toBe(0);
232
+ expect(R.clearPendingWrite('k')(s)).toBe(s);
233
+ });
234
+ it('setFailedCount', () => {
235
+ const s0 = buildState();
236
+ const s1 = R.setFailedCount(2)(s0);
237
+ expect(s1.failedCount).toBe(2);
238
+ expect(R.setFailedCount(2)(s1)).toBe(s1);
239
+ });
240
+ });
241
+
242
+ describe('bucket switch reducers', () => {
243
+ it('rebindQuery keeps the hash, swaps the id and sync state, dirties; clearBucketState wipes per-bucket slices', () => {
244
+ const s0 = buildState(
245
+ [e('a', { lifecycle: { phase: 'live', remote: 'registered', notified: true }, remoteArray: [['t:1', 1]], records: [{ id: 't:1' }], serverState: 'ready', registerAttempts: 2 })],
246
+ R.setVersions([['t:1', 1]]),
247
+ R.outboxReplace([buildOutboxItem()])
248
+ );
249
+ const id = { table: '_00_query', id: 'new' } as any;
250
+ const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
251
+ const en = s1.queries.get('a')!;
252
+ expect(en.def.id).toBe(id);
253
+ expect(en.lifecycle.phase).toBe('cached');
254
+ expect(en.remoteArray).toEqual([['t:2', 1]]);
255
+ expect(en.records).toEqual([]);
256
+ expect(en.serverState).toBeNull();
257
+ expect(en.registerAttempts).toBe(0);
258
+ expect(s1.dirty.has('a')).toBe(true);
259
+ expect(R.rebindQuery('zz', { id, lifecycle: en.lifecycle, remoteArray: [], localArray: [] })(s1)).toBe(s1);
260
+ const s2 = R.clearBucketState()(s1);
261
+ expect(s2.versions.size).toBe(0);
262
+ expect(s2.outbox).toEqual([]);
263
+ expect(s2.dirty.size).toBe(0);
264
+ expect(s2.primed).toBe(false);
265
+ });
266
+ });
267
+
268
+ describe('identity / connection / compose', () => {
269
+ it('sets fields and short-circuits on no change', () => {
270
+ const s0 = buildState();
271
+ const s1 = R.setIdentity({ sessionId: 's', userId: 'u', bucketId: 'b' })(s0);
272
+ expect(s1).toMatchObject({ sessionId: 's', userId: 'u', bucketId: 'b' });
273
+ const s2 = R.setTabRole('leader')(s1);
274
+ expect(R.setTabRole('leader')(s2)).toBe(s2);
275
+ const s3 = R.setConnection('connected')(s2);
276
+ expect(s3.sync.health.connection).toBe('connected');
277
+ expect(R.setConnection('connected')(s3)).toBe(s3);
278
+ const s4 = R.setHealth({ ...s3.sync.health, status: 'degraded' })(s3);
279
+ expect(s4.sync.health.status).toBe('degraded');
280
+ const s5 = R.patchSync({ pollIdleStreak: 4 })(s4);
281
+ expect(s5.sync.pollIdleStreak).toBe(4);
282
+ const s6 = R.compose(R.setFailedCount(1), R.setFailedCount(3))(s5);
283
+ expect(s6.failedCount).toBe(3);
284
+ });
285
+ });
286
+
287
+ describe('durable view index', () => {
288
+ it('commit and children keep a view key in step with its `_00_view` row; cold children are not indexed', () => {
289
+ let s = buildState([e('a', { def: { viewKey: 'va' }, lifecycle: { phase: 'cold' }, subqueryRemoteArray: [['c:1', 1]] })]);
290
+ s = R.setSubqueryRemoteArray('a', [['c:1', 1], ['c:2', 1]])(s);
291
+ expect(s.views.size).toBe(0);
292
+ s = R.commitMembership('a', [['t:1', 1], ['c:1', 1]], true)(s);
293
+ expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:2']);
294
+ s = R.setSubqueryRemoteArray('a', [['c:3', 1]])(s);
295
+ expect(s.views.get('va')).toEqual(['t:1', 'c:1', 'c:3']);
296
+ expect(R.setSubqueryRemoteArray('zz', [['c:9', 1]])(s)).toBe(s);
297
+ expect(R.clearBucketState()(s).views.size).toBe(0);
298
+ });
299
+ it('a reload takes the store, except entries of queries in state', () => {
300
+ const s = R.compose(R.commitMembership('a', [['t:new', 1]], true))(buildState([e('a', { def: { viewKey: 'va' } }), e('b', { def: { viewKey: 'vb' } })]));
301
+ const reloaded = R.reloadViews(new Map([['va', ['t:old']], ['vb', ['t:b']], ['vc', ['t:c']]]))(s);
302
+ expect(reloaded.views).toEqual(new Map([['va', ['t:new']], ['vb', ['t:b']], ['vc', ['t:c']]]));
303
+ });
304
+ });