@spooky-sync/core 0.0.1-canary.35 → 0.0.1-canary.351

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