@spooky-sync/core 0.0.1-canary.27 → 0.0.1-canary.270

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2279 -81
  4. package/dist/index.js +13490 -2585
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +939 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +528 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +443 -0
  54. package/src/modules/auth/impersonation.test.ts +355 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +322 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +283 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +603 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. package/src/spooky.ts +0 -395
@@ -0,0 +1,191 @@
1
+ import type { QueryPlan, RecordId } from '@spooky-sync/query-builder';
2
+ import type {
3
+ ConnectionState,
4
+ MutationEventType,
5
+ QueryHash,
6
+ QueryTimeToLive,
7
+ RecordVersionArray,
8
+ RegistrationTimings,
9
+ SyncHealth,
10
+ } from '../types';
11
+ import type { QueryLifecycle } from './lifecycle';
12
+ import type { LaneState } from '../kernel/saga';
13
+ import { emptyLanes } from '../kernel/saga';
14
+
15
+ export type Row = Record<string, unknown>;
16
+
17
+ /** Everything a query is, minus anything that changes after registration. */
18
+ export interface QueryDefinition {
19
+ /** Session-salted `_00_query:<hash>` record id: the remote view id. */
20
+ readonly id: RecordId<string>;
21
+ readonly hash: QueryHash;
22
+ /** Unsalted `sha256({surql, params})`: key of the durable `_00_view` row. */
23
+ readonly viewKey: string;
24
+ readonly surql: string;
25
+ readonly params: Readonly<Record<string, unknown>>;
26
+ readonly plan?: QueryPlan;
27
+ readonly ttl: QueryTimeToLive;
28
+ readonly ttlMs: number;
29
+ readonly tableName: string;
30
+ readonly createdAt: number;
31
+ }
32
+
33
+ export type ServerViewState = 'materializing' | 'ready' | null;
34
+
35
+ export interface QueryTelemetry {
36
+ readonly updateCount: number;
37
+ readonly errorCount: number;
38
+ readonly lastUpdatedAt: number | null;
39
+ readonly materializationSamples: ReadonlyArray<number>;
40
+ readonly lastIngestLatencyMs: number | null;
41
+ readonly phaseSamples: Readonly<Record<string, ReadonlyArray<number>>>;
42
+ readonly phaseLast: Readonly<Record<string, number | null>>;
43
+ readonly registrationTimings: RegistrationTimings;
44
+ }
45
+
46
+ export interface QueryEntry {
47
+ readonly def: QueryDefinition;
48
+ readonly lifecycle: QueryLifecycle;
49
+ /** Server membership (ids + versions) or the durable seed. */
50
+ readonly remoteArray: RecordVersionArray;
51
+ /** The in-browser SSP view's id-set: local truth for "does this row match". */
52
+ readonly localArray: RecordVersionArray;
53
+ readonly subqueryRemoteArray: RecordVersionArray;
54
+ readonly records: ReadonlyArray<Row>;
55
+ readonly serverState: ServerViewState;
56
+ readonly subscribers: number;
57
+ readonly lastSubscriberLeftAt: number | null;
58
+ readonly lastHeartbeatAt: number | null;
59
+ readonly lastPolledAt: number | null;
60
+ readonly registerAttempts: number;
61
+ /**
62
+ * Re-registrations issued because the `_00_query` row read as gone, since
63
+ * the row was last seen. Paces the next one (see `applyMembership`).
64
+ */
65
+ readonly viewLostCount: number;
66
+ /** When the paced re-registration is due; null when none is pending. */
67
+ readonly viewLostRetryAt: number | null;
68
+ readonly telemetry: QueryTelemetry;
69
+ }
70
+
71
+ export type OutboxStatus = 'pending' | 'acked';
72
+
73
+ /** In-memory mirror of one `_00_pending_mutations` row plus its push progress. */
74
+ export interface OutboxItem {
75
+ readonly id: string;
76
+ readonly type: MutationEventType;
77
+ readonly recordId: string;
78
+ readonly table: string;
79
+ readonly status: OutboxStatus;
80
+ readonly ackedAt: number | null;
81
+ readonly attempts: number;
82
+ }
83
+
84
+ export type TabRole = 'solo' | 'leader' | 'follower';
85
+
86
+ /** A debounced update accumulating locally until its flush writes the outbox row. */
87
+ export interface PendingWrite {
88
+ readonly key: string;
89
+ readonly table: string;
90
+ readonly recordId: string;
91
+ readonly data: Readonly<Record<string, unknown>>;
92
+ readonly before: Readonly<Record<string, unknown>> | null;
93
+ readonly firstAt: number;
94
+ }
95
+
96
+ export interface SyncSlice {
97
+ readonly health: SyncHealth;
98
+ readonly consecutiveFailures: number;
99
+ readonly hasSyncedOnce: boolean;
100
+ readonly selfHealAttempts: number;
101
+ readonly pollIdleStreak: number;
102
+ readonly lastReconnectRefetchAt: number | null;
103
+ readonly needsResubscribe: boolean;
104
+ readonly fetchAttempts: number;
105
+ readonly liveUuid: string | null;
106
+ readonly liveTable: string | null;
107
+ readonly lanes: LaneState;
108
+ }
109
+
110
+ export interface ClientState {
111
+ readonly sessionId: string | null;
112
+ readonly userId: string | null;
113
+ /** The principal the current session salt was minted for. */
114
+ readonly saltUserId: string | null;
115
+ /** Latest bucket-switch target; an older switch that wakes up to a newer target skips. */
116
+ readonly pendingBucket: string | null;
117
+ readonly tabId: string;
118
+ readonly tabRole: TabRole;
119
+ readonly bucketId: string | null;
120
+ readonly localReady: boolean;
121
+ readonly primed: boolean;
122
+ readonly queries: ReadonlyMap<QueryHash, QueryEntry>;
123
+ /** Hashes whose local registration is in flight (dedupes concurrent `query()` calls). */
124
+ readonly registering: ReadonlySet<QueryHash>;
125
+ /** Re-read attempts per hash while the server reports a view as `materializing`. */
126
+ readonly membershipReread: ReadonlyMap<QueryHash, number>;
127
+ /** Local body versions (`_00_rv`) by encoded record id. */
128
+ readonly versions: ReadonlyMap<string, number>;
129
+ readonly outbox: ReadonlyArray<OutboxItem>;
130
+ readonly pendingWrites: ReadonlyMap<string, PendingWrite>;
131
+ readonly failedCount: number;
132
+ /** Queries whose render inputs changed since their last materialization. */
133
+ readonly dirty: ReadonlySet<QueryHash>;
134
+ /** Queries whose server membership must be re-read. */
135
+ readonly membershipDirty: ReadonlySet<QueryHash>;
136
+ readonly sync: SyncSlice;
137
+ }
138
+
139
+ export const emptyTelemetry = (): QueryTelemetry => ({
140
+ updateCount: 0,
141
+ errorCount: 0,
142
+ lastUpdatedAt: null,
143
+ materializationSamples: [],
144
+ lastIngestLatencyMs: null,
145
+ phaseSamples: {},
146
+ phaseLast: {},
147
+ registrationTimings: { parseMs: null, planMs: null, snapshotMs: null, wallMs: null },
148
+ });
149
+
150
+ export const initialHealth = (connection: ConnectionState = 'disconnected'): SyncHealth => ({
151
+ status: 'healthy',
152
+ consecutiveFailures: 0,
153
+ everConnected: false,
154
+ connection,
155
+ });
156
+
157
+ export function emptyState(init: { tabId: string }): ClientState {
158
+ return {
159
+ sessionId: null,
160
+ userId: null,
161
+ saltUserId: null,
162
+ pendingBucket: null,
163
+ tabId: init.tabId,
164
+ tabRole: 'solo',
165
+ bucketId: null,
166
+ localReady: false,
167
+ primed: false,
168
+ queries: new Map(),
169
+ registering: new Set(),
170
+ membershipReread: new Map(),
171
+ versions: new Map(),
172
+ outbox: [],
173
+ pendingWrites: new Map(),
174
+ failedCount: 0,
175
+ dirty: new Set(),
176
+ membershipDirty: new Set(),
177
+ sync: {
178
+ health: initialHealth(),
179
+ consecutiveFailures: 0,
180
+ hasSyncedOnce: false,
181
+ selfHealAttempts: 0,
182
+ pollIdleStreak: 0,
183
+ lastReconnectRefetchAt: null,
184
+ needsResubscribe: false,
185
+ fetchAttempts: 0,
186
+ liveUuid: null,
187
+ liveTable: null,
188
+ lanes: emptyLanes(),
189
+ },
190
+ };
191
+ }
@@ -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,283 @@
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 };
225
+ let s = R.mergePendingWrite(w)(buildState());
226
+ s = R.mergePendingWrite({ ...w, data: { b: 2 }, before: null, firstAt: 9 })(s);
227
+ expect(s.pendingWrites.get('k')).toEqual({ ...w, data: { a: 1, b: 2 } });
228
+ s = R.clearPendingWrite('k')(s);
229
+ expect(s.pendingWrites.size).toBe(0);
230
+ expect(R.clearPendingWrite('k')(s)).toBe(s);
231
+ });
232
+ it('setFailedCount', () => {
233
+ const s0 = buildState();
234
+ const s1 = R.setFailedCount(2)(s0);
235
+ expect(s1.failedCount).toBe(2);
236
+ expect(R.setFailedCount(2)(s1)).toBe(s1);
237
+ });
238
+ });
239
+
240
+ describe('bucket switch reducers', () => {
241
+ it('rebindQuery keeps the hash, swaps the id and sync state, dirties; clearBucketState wipes per-bucket slices', () => {
242
+ const s0 = buildState(
243
+ [e('a', { lifecycle: { phase: 'live', remote: 'registered', notified: true }, remoteArray: [['t:1', 1]], records: [{ id: 't:1' }], serverState: 'ready', registerAttempts: 2 })],
244
+ R.setVersions([['t:1', 1]]),
245
+ R.outboxReplace([buildOutboxItem()])
246
+ );
247
+ const id = { table: '_00_query', id: 'new' } as any;
248
+ const s1 = R.rebindQuery('a', { id, lifecycle: { phase: 'cached', remote: 'unregistered', fetchDepth: 0, notified: false }, remoteArray: [['t:2', 1]], localArray: [] })({ ...s0, dirty: new Set() });
249
+ const en = s1.queries.get('a')!;
250
+ expect(en.def.id).toBe(id);
251
+ expect(en.lifecycle.phase).toBe('cached');
252
+ expect(en.remoteArray).toEqual([['t:2', 1]]);
253
+ expect(en.records).toEqual([]);
254
+ expect(en.serverState).toBeNull();
255
+ expect(en.registerAttempts).toBe(0);
256
+ expect(s1.dirty.has('a')).toBe(true);
257
+ expect(R.rebindQuery('zz', { id, lifecycle: en.lifecycle, remoteArray: [], localArray: [] })(s1)).toBe(s1);
258
+ const s2 = R.clearBucketState()(s1);
259
+ expect(s2.versions.size).toBe(0);
260
+ expect(s2.outbox).toEqual([]);
261
+ expect(s2.dirty.size).toBe(0);
262
+ expect(s2.primed).toBe(false);
263
+ });
264
+ });
265
+
266
+ describe('identity / connection / compose', () => {
267
+ it('sets fields and short-circuits on no change', () => {
268
+ const s0 = buildState();
269
+ const s1 = R.setIdentity({ sessionId: 's', userId: 'u', bucketId: 'b' })(s0);
270
+ expect(s1).toMatchObject({ sessionId: 's', userId: 'u', bucketId: 'b' });
271
+ const s2 = R.setTabRole('leader')(s1);
272
+ expect(R.setTabRole('leader')(s2)).toBe(s2);
273
+ const s3 = R.setConnection('connected')(s2);
274
+ expect(s3.sync.health.connection).toBe('connected');
275
+ expect(R.setConnection('connected')(s3)).toBe(s3);
276
+ const s4 = R.setHealth({ ...s3.sync.health, status: 'degraded' })(s3);
277
+ expect(s4.sync.health.status).toBe('degraded');
278
+ const s5 = R.patchSync({ pollIdleStreak: 4 })(s4);
279
+ expect(s5.sync.pollIdleStreak).toBe(4);
280
+ const s6 = R.compose(R.setFailedCount(1), R.setFailedCount(3))(s5);
281
+ expect(s6.failedCount).toBe(3);
282
+ });
283
+ });