@spooky-sync/core 0.0.1-canary.29 → 0.0.1-canary.290
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.
- package/AGENTS.md +81 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2027 -137
- package/dist/index.js +14564 -2472
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +329 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +358 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +472 -0
- package/dist/types.d.ts +1700 -38
- package/dist/worker-edges.js +423 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +27 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +172 -0
- package/skills/sp00ky-core/SKILL.md +31 -6
- package/skills/sp00ky-core/references/config.md +6 -0
- package/src/boot/auth-flip.saga.test.ts +82 -0
- package/src/boot/auth-flip.saga.ts +62 -0
- package/src/boot/boot.saga.test.ts +158 -0
- package/src/boot/boot.saga.ts +131 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +51 -0
- package/src/boot/preload.saga.ts +31 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +117 -0
- package/src/client/bucket-handle.ts +227 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.ts +366 -0
- package/src/client/sp00ky-client.test.ts +271 -0
- package/src/client/sp00ky-client.ts +541 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +161 -0
- package/src/kernel/events.ts +58 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +141 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.test.ts +355 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +386 -17
- package/src/modules/crdt/crdt-field.ts +294 -0
- package/src/modules/crdt/crdt-hydration.test.ts +210 -0
- package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
- package/src/modules/crdt/index.ts +463 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +519 -37
- package/src/modules/devtools/notify-throttle.test.ts +154 -0
- package/src/modules/devtools/state-shape.test.ts +149 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +251 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +91 -0
- package/src/modules/ref-tables.ts +88 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +285 -0
- package/src/mutation/push.saga.ts +217 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +56 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +159 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +350 -0
- package/src/query/membership.saga.ts +278 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +293 -0
- package/src/query/register.saga.ts +226 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +63 -0
- package/src/query/sql.ts +93 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +195 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +199 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +144 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +59 -16
- package/src/services/database/local.test.ts +64 -0
- package/src/services/database/local.ts +487 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +467 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +213 -6
- package/src/services/database/sqlite-cache-engine.test.ts +617 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1372 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +139 -0
- package/src/services/database/sqlite-plan-sql.ts +179 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +246 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +416 -0
- package/src/services/database/surreal-cache-engine.ts +161 -0
- package/src/services/logger/index.ts +3 -2
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +779 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +283 -0
- package/src/services/tabs/broker.test.ts +327 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +638 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +640 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +200 -0
- package/src/state/lifecycle.test.ts +118 -0
- package/src/state/lifecycle.ts +86 -0
- package/src/state/reducers.test.ts +304 -0
- package/src/state/reducers.ts +526 -0
- package/src/state/selectors.test.ts +167 -0
- package/src/state/selectors.ts +216 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +86 -0
- package/src/sync/connection.saga.ts +85 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +659 -0
- package/src/sync/policy.ts +502 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +133 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +34 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +624 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +75 -9
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +30 -18
- package/src/utils/utils.test.ts +2 -1
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { runPure } from '../testing/run-pure';
|
|
3
|
+
import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
|
|
4
|
+
import * as R from '../state/reducers';
|
|
5
|
+
import { defaultEnv } from '../query/env';
|
|
6
|
+
import { connectionChanged, selfHealTick, syncOutcome } from './connection.saga';
|
|
7
|
+
|
|
8
|
+
const env = defaultEnv({ tables: [] } as any);
|
|
9
|
+
|
|
10
|
+
describe('connectionChanged', () => {
|
|
11
|
+
it('a drop arms resubscribe and invalidates LIVE; the initial connect does nothing else', async () => {
|
|
12
|
+
const s = R.patchSync({ liveUuid: 'u', liveTable: 't' })(buildState());
|
|
13
|
+
const dropped = await runPure(connectionChanged(env, 'reconnecting'), { state: s });
|
|
14
|
+
expect(dropped.state.sync).toMatchObject({ needsResubscribe: true, liveUuid: null, health: { connection: 'reconnecting' } });
|
|
15
|
+
const first = await runPure(connectionChanged(env, 'connected'), { state: buildState() });
|
|
16
|
+
expect(first.dispatched).toEqual([]);
|
|
17
|
+
const connecting = await runPure(connectionChanged(env, 'connecting'), { state: dropped.state });
|
|
18
|
+
expect(connecting.dispatched).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
it('reconnect after a drop drops every registration and re-drives, once per cooldown window', async () => {
|
|
21
|
+
const s = R.patchSync({ needsResubscribe: true })(
|
|
22
|
+
buildState([
|
|
23
|
+
buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'registered' } }),
|
|
24
|
+
buildEntry({ def: { hash: 'b' }, lifecycle: { remote: 'failed' } }),
|
|
25
|
+
buildEntry({ def: { hash: 'c' } }),
|
|
26
|
+
])
|
|
27
|
+
);
|
|
28
|
+
const out = await runPure(connectionChanged(env, 'connected'), { state: s, now: 1000 });
|
|
29
|
+
expect([...out.state.queries.values()].map((e) => e.lifecycle.remote)).toEqual(['unregistered', 'unregistered', 'unregistered']);
|
|
30
|
+
expect(out.state.sync).toMatchObject({ needsResubscribe: false, lastReconnectRefetchAt: 1000 });
|
|
31
|
+
expect(out.dispatched).toEqual([{ type: 'EnsureRegistered', requireAuth: true }, { type: 'LiveStart' }, { type: 'Drain' }]);
|
|
32
|
+
const burst = await runPure(connectionChanged(env, 'connected'), { state: R.patchSync({ needsResubscribe: true })(out.state), now: 5000 });
|
|
33
|
+
expect(burst.dispatched).toEqual([]);
|
|
34
|
+
expect(burst.state.sync.needsResubscribe).toBe(false);
|
|
35
|
+
const later = await runPure(connectionChanged(env, 'connected'), { state: R.patchSync({ needsResubscribe: true })(out.state), now: 20_000 });
|
|
36
|
+
expect(later.dispatched).toHaveLength(3);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('syncOutcome', () => {
|
|
41
|
+
it('degrades after the threshold (arming self-heal), recovers on success (disarming it)', async () => {
|
|
42
|
+
let state = buildState();
|
|
43
|
+
for (let i = 0; i < 2; i++) state = (await runPure(syncOutcome(env, false, new Error('socket')), { state })).state;
|
|
44
|
+
expect(state.sync.health.status).toBe('healthy');
|
|
45
|
+
const third = await runPure(syncOutcome(env, false, new Error('socket')), { state });
|
|
46
|
+
expect(third.state.sync.health.status).toBe('degraded');
|
|
47
|
+
// The saga emits nothing: the runtime notifies `health:changed` off the
|
|
48
|
+
// health object's identity, which `setHealth` only replaces on a real move.
|
|
49
|
+
expect(third.emitted).toEqual([]);
|
|
50
|
+
expect(third.timers.get('heal')).toEqual({ ms: 2000, event: { type: 'SelfHealTick' } });
|
|
51
|
+
expect(third.state.sync.selfHealAttempts).toBe(0);
|
|
52
|
+
const back = await runPure(syncOutcome(env, true), { state: third.state });
|
|
53
|
+
expect(back.state.sync.health.status).toBe('healthy');
|
|
54
|
+
expect(back.state.sync.health).not.toBe(third.state.sync.health);
|
|
55
|
+
expect(back.log.some((e) => e.kind === 'timer.clear' && (e as any).key === 'heal')).toBe(true);
|
|
56
|
+
const quiet = await runPure(syncOutcome(env, true), { state: back.state });
|
|
57
|
+
expect(quiet.state.sync.health).toBe(back.state.sync.health);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('selfHealTick', () => {
|
|
62
|
+
const degraded = (s: ReturnType<typeof buildState>) =>
|
|
63
|
+
R.compose(R.setHealth({ ...s.sync.health, status: 'degraded' }), R.patchSync({ selfHealAttempts: 1 }))(s);
|
|
64
|
+
it('does nothing when healthy; drains first, then re-registers (failed included), else probes; always re-arms', async () => {
|
|
65
|
+
expect((await runPure(selfHealTick(env), { state: buildState() })).timers.size).toBe(0);
|
|
66
|
+
const withOutbox = await runPure(selfHealTick(env), { state: degraded(buildState([], R.outboxReplace([buildOutboxItem()]))) });
|
|
67
|
+
expect(withOutbox.dispatched).toEqual([{ type: 'Drain' }]);
|
|
68
|
+
expect(withOutbox.timers.get('heal')).toEqual({ ms: 8000, event: { type: 'SelfHealTick' } });
|
|
69
|
+
const withFailed = await runPure(selfHealTick(env), { state: degraded(buildState([buildEntry({ def: { hash: 'a' }, lifecycle: { remote: 'failed' } })])) });
|
|
70
|
+
expect(withFailed.state.queries.get('a')!.lifecycle.remote).toBe('unregistered');
|
|
71
|
+
expect(withFailed.dispatched).toEqual([{ type: 'EnsureRegistered', requireAuth: true }]);
|
|
72
|
+
const withDesired = await runPure(selfHealTick(env), { state: degraded(buildState([buildEntry({ def: { hash: 'a' } })])) });
|
|
73
|
+
expect(withDesired.dispatched).toEqual([{ type: 'EnsureRegistered', requireAuth: true }]);
|
|
74
|
+
const probe = await runPure(selfHealTick(env), { state: degraded(buildState()), handlers: { 'remote.query': () => [{ status: 'OK', result: true }] } });
|
|
75
|
+
expect(probe.dispatched).toEqual([{ type: 'SyncOutcome', ok: true }]);
|
|
76
|
+
const probeFail = await runPure(selfHealTick(env), {
|
|
77
|
+
state: degraded(buildState()),
|
|
78
|
+
handlers: {
|
|
79
|
+
'remote.query': () => {
|
|
80
|
+
throw new Error('offline');
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
expect(probeFail.dispatched).toEqual([{ type: 'SyncOutcome', ok: false, error: new Error('offline') }]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ConnectionState } from '../types';
|
|
2
|
+
import type { Saga } from '../kernel/saga';
|
|
3
|
+
import { fx } from '../kernel/effects';
|
|
4
|
+
import { RECONNECT_REFETCH_COOLDOWN_MS } from '../kernel/constants';
|
|
5
|
+
import type { ClientState } from '../state/client-state';
|
|
6
|
+
import * as R from '../state/reducers';
|
|
7
|
+
import { desiredRegistrations, pendingMutationCount } from '../state/selectors';
|
|
8
|
+
import type { SagaEnv } from '../query/env';
|
|
9
|
+
import { liveInvalidate } from './live.saga';
|
|
10
|
+
import { nextHealth, selfHealDelayMs } from './policy';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Transport state changes. A drop invalidates the live query and arms a
|
|
14
|
+
* resubscribe; the next `connected` (outside the burst cooldown) drops every
|
|
15
|
+
* remote registration so `ensureRegistered` rebuilds them behind the
|
|
16
|
+
* `$auth.id` gate, and restarts LIVE.
|
|
17
|
+
*/
|
|
18
|
+
export function* connectionChanged(env: SagaEnv, connection: ConnectionState): Saga<void> {
|
|
19
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
20
|
+
yield fx.state.update(R.setConnection(connection));
|
|
21
|
+
if (connection === 'disconnected' || connection === 'reconnecting') {
|
|
22
|
+
yield fx.state.update(R.patchSync({ needsResubscribe: true }));
|
|
23
|
+
yield* liveInvalidate();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (connection !== 'connected' || !state.sync.needsResubscribe) return;
|
|
27
|
+
const now = (yield fx.now()) as number;
|
|
28
|
+
const last = state.sync.lastReconnectRefetchAt;
|
|
29
|
+
if (last !== null && now - last < RECONNECT_REFETCH_COOLDOWN_MS) {
|
|
30
|
+
yield fx.state.update(R.patchSync({ needsResubscribe: false }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
yield fx.state.update(
|
|
34
|
+
R.compose(
|
|
35
|
+
R.patchSync({ needsResubscribe: false, lastReconnectRefetchAt: now }),
|
|
36
|
+
...[...state.queries.values()]
|
|
37
|
+
.filter((e) => e.lifecycle.remote !== 'unregistered')
|
|
38
|
+
.map((e) => R.applyLifecycle(e.def.hash, { type: 'remote-dropped' }))
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
yield fx.dispatch({ type: 'EnsureRegistered', requireAuth: true });
|
|
42
|
+
yield fx.dispatch({ type: 'LiveStart' });
|
|
43
|
+
yield fx.dispatch({ type: 'Drain' });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fold one sync round's outcome into health; start / stop self-heal. */
|
|
47
|
+
export function* syncOutcome(env: SagaEnv, ok: boolean, error?: unknown): Saga<void> {
|
|
48
|
+
const sync = (yield fx.state.read((s) => s.sync)) as ClientState['sync'];
|
|
49
|
+
const next = nextHealth({ health: sync.health, consecutiveFailures: sync.consecutiveFailures, hasSyncedOnce: sync.hasSyncedOnce }, ok, error, env.degradeAfter);
|
|
50
|
+
yield fx.state.update(
|
|
51
|
+
R.compose(R.setHealth(next.health), R.patchSync({ consecutiveFailures: next.consecutiveFailures, hasSyncedOnce: next.hasSyncedOnce }))
|
|
52
|
+
);
|
|
53
|
+
if (next.degradedNow) {
|
|
54
|
+
yield fx.state.update(R.patchSync({ selfHealAttempts: 0 }));
|
|
55
|
+
yield fx.timer.set('heal', selfHealDelayMs(0), { type: 'SelfHealTick' });
|
|
56
|
+
}
|
|
57
|
+
if (next.recoveredNow) yield fx.timer.clear('heal');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* While degraded: re-drive whatever can prove the server is back, on a
|
|
62
|
+
* growing backoff. Retry the outbox first, then the registrations (failed
|
|
63
|
+
* ones included, behind the identity gate), else a bare probe.
|
|
64
|
+
*/
|
|
65
|
+
export function* selfHealTick(env: SagaEnv): Saga<void> {
|
|
66
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
67
|
+
if (state.sync.health.status !== 'degraded') return;
|
|
68
|
+
const attempt = state.sync.selfHealAttempts + 1;
|
|
69
|
+
yield fx.state.update(R.patchSync({ selfHealAttempts: attempt }));
|
|
70
|
+
const failed = [...state.queries.values()].filter((e) => e.lifecycle.remote === 'failed').map((e) => e.def.hash);
|
|
71
|
+
if (pendingMutationCount(state) > 0) {
|
|
72
|
+
yield fx.dispatch({ type: 'Drain' });
|
|
73
|
+
} else if (desiredRegistrations(state).length > 0 || failed.length > 0) {
|
|
74
|
+
if (failed.length > 0) yield fx.state.update(R.compose(...failed.map((h) => R.applyLifecycle(h, { type: 'remote-dropped' }))));
|
|
75
|
+
yield fx.dispatch({ type: 'EnsureRegistered', requireAuth: true });
|
|
76
|
+
} else {
|
|
77
|
+
try {
|
|
78
|
+
yield fx.remote.query('RETURN true', undefined, env.remoteTimeoutMs);
|
|
79
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: true });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
yield fx.dispatch({ type: 'SyncOutcome', ok: false, error });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
yield fx.timer.set('heal', selfHealDelayMs(attempt), { type: 'SelfHealTick' });
|
|
85
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { RecordId } from 'surrealdb';
|
|
3
|
+
import { runPure } from '../testing/run-pure';
|
|
4
|
+
import { buildEntry, buildState } from '../testing/build';
|
|
5
|
+
import * as R from '../state/reducers';
|
|
6
|
+
import { defaultEnv } from '../query/env';
|
|
7
|
+
import { liveChange, liveInvalidate, liveStart } from './live.saga';
|
|
8
|
+
import { rowOfEdge } from '../client/services';
|
|
9
|
+
|
|
10
|
+
const env = defaultEnv({ tables: [] } as any);
|
|
11
|
+
|
|
12
|
+
describe('liveStart', () => {
|
|
13
|
+
it('subscribes on the session table and records the uuid; no-op when already on it; followers never subscribe', async () => {
|
|
14
|
+
const out = await runPure(liveStart(env), { state: { ...buildState(), userId: 'user:abc' }, handlers: { 'remote.live': (e: any) => `uuid-${e.table}` } });
|
|
15
|
+
expect(out.state.sync).toMatchObject({ liveUuid: 'uuid-_00_list_ref_user_abc', liveTable: '_00_list_ref_user_abc' });
|
|
16
|
+
const same = await runPure(liveStart(env), { state: out.state });
|
|
17
|
+
expect(same.log.filter((e) => e.kind === 'remote.live')).toHaveLength(0);
|
|
18
|
+
const follower = await runPure(liveStart(env), { state: { ...buildState(), tabRole: 'follower' } });
|
|
19
|
+
expect(follower.log.filter((e) => e.kind === 'remote.live')).toHaveLength(0);
|
|
20
|
+
});
|
|
21
|
+
it('kills the previous subscription when connected (ignoring kill errors), tolerates a failing subscribe', async () => {
|
|
22
|
+
const prev = R.compose(R.patchSync({ liveUuid: 'old', liveTable: '_00_list_ref' }), R.setConnection('connected'))({ ...buildState(), userId: 'user:x' });
|
|
23
|
+
const killed: string[] = [];
|
|
24
|
+
const out = await runPure(liveStart(env), {
|
|
25
|
+
state: prev,
|
|
26
|
+
handlers: {
|
|
27
|
+
'remote.kill': (e: any) => {
|
|
28
|
+
killed.push(e.uuid);
|
|
29
|
+
throw new Error('gone');
|
|
30
|
+
},
|
|
31
|
+
'remote.live': () => 'new',
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
expect(killed).toEqual(['old']);
|
|
35
|
+
expect(out.state.sync.liveUuid).toBe('new');
|
|
36
|
+
const clean = await runPure(liveStart(env), { state: prev, handlers: { 'remote.kill': () => undefined, 'remote.live': () => 'new2' } });
|
|
37
|
+
expect(clean.state.sync.liveUuid).toBe('new2');
|
|
38
|
+
expect(clean.emitted).toEqual([]);
|
|
39
|
+
const offline = R.patchSync({ liveUuid: 'old', liveTable: '_00_list_ref' })({ ...buildState(), userId: 'user:x' });
|
|
40
|
+
const noKill = await runPure(liveStart(env), {
|
|
41
|
+
state: offline,
|
|
42
|
+
handlers: {
|
|
43
|
+
'remote.live': () => {
|
|
44
|
+
throw new Error('not connected');
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
expect(noKill.log.filter((e) => e.kind === 'remote.kill')).toHaveLength(0);
|
|
49
|
+
expect(noKill.state.sync.liveUuid).toBeNull();
|
|
50
|
+
expect(noKill.emitted).toEqual([expect.objectContaining({ level: 'warn' })]);
|
|
51
|
+
});
|
|
52
|
+
it('liveInvalidate clears the bookkeeping', async () => {
|
|
53
|
+
const out = await runPure(liveInvalidate(), { state: R.patchSync({ liveUuid: 'u', liveTable: 't' })(buildState()) });
|
|
54
|
+
expect(out.state.sync).toMatchObject({ liveUuid: null, liveTable: null });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('liveChange', () => {
|
|
59
|
+
it('marks known hashes dirty, resets the poll streak, relays as leader; ignores unknown hashes', async () => {
|
|
60
|
+
const s = R.patchSync({ pollIdleStreak: 3 })(buildState([buildEntry({ def: { hash: 'a' } })]));
|
|
61
|
+
const out = await runPure(liveChange(env, ['a', 'zz']), { state: { ...s, tabRole: 'leader' } });
|
|
62
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
63
|
+
expect(out.state.sync.pollIdleStreak).toBe(0);
|
|
64
|
+
expect(out.timers.get('membership')).toEqual({ ms: 50, event: { type: 'ReadDirtyMembership' } });
|
|
65
|
+
expect(out.emitted).toEqual([{ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: ['a'] } }]);
|
|
66
|
+
const solo = await runPure(liveChange(env, ['a']), { state: s });
|
|
67
|
+
expect(solo.emitted).toEqual([]);
|
|
68
|
+
const none = await runPure(liveChange(env, ['zz']), { state: s });
|
|
69
|
+
expect(none.timers.size).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('pulls a backed-off poll back to the base cadence, and leaves an already-base one alone', async () => {
|
|
73
|
+
const entry = buildEntry({ def: { hash: 'a' } });
|
|
74
|
+
// Coasted up to the cap: the armed timer is the reason a change the poll
|
|
75
|
+
// has to catch could wait 5s, so the event has to re-arm it, not just zero
|
|
76
|
+
// the streak the next tick would read.
|
|
77
|
+
const backedOff = R.patchSync({ pollIdleStreak: 4 })(buildState([entry]));
|
|
78
|
+
const out = await runPure(liveChange(env, ['a']), { state: backedOff });
|
|
79
|
+
expect(out.timers.get('poll')).toEqual({ ms: env.pollBaseMs, event: { type: 'PollTick' } });
|
|
80
|
+
|
|
81
|
+
// Already at base: re-arming here would push the poll out by another
|
|
82
|
+
// `pollBaseMs` on every notification and starve it under sustained LIVE.
|
|
83
|
+
const atBase = R.patchSync({ pollIdleStreak: 0 })(buildState([entry]));
|
|
84
|
+
const quiet = await runPure(liveChange(env, ['a']), { state: atBase });
|
|
85
|
+
expect(quiet.timers.has('poll')).toBe(false);
|
|
86
|
+
expect(quiet.state.membershipDirty.has('a')).toBe(true);
|
|
87
|
+
|
|
88
|
+
// An unknown hash is not activity: nothing is touched.
|
|
89
|
+
const unknown = await runPure(liveChange(env, ['zz']), { state: backedOff });
|
|
90
|
+
expect(unknown.timers.has('poll')).toBe(false);
|
|
91
|
+
expect(unknown.state.sync.pollIdleStreak).toBe(4);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('rowOfEdge', () => {
|
|
96
|
+
const rid = new RecordId('game', 'g1');
|
|
97
|
+
|
|
98
|
+
it('reads the joined body off the notification', () => {
|
|
99
|
+
expect(rowOfEdge({ in: new RecordId('_00_query', 'a'), out: { id: rid, result: '1-0' }, version: 7 })).toEqual({
|
|
100
|
+
id: 'game:g1',
|
|
101
|
+
version: 7,
|
|
102
|
+
record: { id: rid, result: '1-0' },
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('yields nothing when there is no body to read', () => {
|
|
107
|
+
// No FETCH clause: `out` is still just the record id.
|
|
108
|
+
expect(rowOfEdge({ out: rid, version: 7 })).toBeNull();
|
|
109
|
+
// The session may not read the row, or its target is gone.
|
|
110
|
+
expect(rowOfEdge({ out: null, version: 7 })).toBeNull();
|
|
111
|
+
// A body with no version cannot be placed against what we already hold.
|
|
112
|
+
expect(rowOfEdge({ out: { id: rid }, version: undefined })).toBeNull();
|
|
113
|
+
// `landChunk` keys off a real RecordId; a string id would record the
|
|
114
|
+
// version for a body it never wrote.
|
|
115
|
+
expect(rowOfEdge({ out: { id: 'game:g1' }, version: 7 })).toBeNull();
|
|
116
|
+
expect(rowOfEdge(null)).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('liveChange with the body joined on', () => {
|
|
121
|
+
const rid = new RecordId('game', 'g1');
|
|
122
|
+
const row = (version: number) => ({ id: 'game:g1', version, record: { id: rid, result: '1-0' } });
|
|
123
|
+
const handlers = { 'local.execute': () => undefined, 'ssp.ingest': () => undefined };
|
|
124
|
+
|
|
125
|
+
it('lands the pushed body so the fetch plan has nothing left to pull', async () => {
|
|
126
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
127
|
+
const out = await runPure(liveChange(env, ['a'], [row(7)]), { state: s, handlers });
|
|
128
|
+
|
|
129
|
+
// Recorded at the edge's version: this is what makes `planFetch` skip it.
|
|
130
|
+
expect(out.state.versions.get('game:g1')).toBe(7);
|
|
131
|
+
// Written to the store and ingested into the circuit, exactly as a fetch would.
|
|
132
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(1);
|
|
133
|
+
expect(out.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(1);
|
|
134
|
+
// Still a doorbell: membership is re-read regardless.
|
|
135
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('ignores a body we already hold at that version or newer', async () => {
|
|
139
|
+
const s = R.setVersions([['game:g1', 7]])(buildState([buildEntry({ def: { hash: 'a' } })]));
|
|
140
|
+
const out = await runPure(liveChange(env, ['a'], [row(7)]), { state: s, handlers });
|
|
141
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
|
|
142
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('leaves a failed inline body for the fetch path without advancing its version', async () => {
|
|
146
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
147
|
+
const out = await runPure(liveChange(env, ['a'], [row(7)]), {
|
|
148
|
+
state: s,
|
|
149
|
+
handlers: {
|
|
150
|
+
'local.execute': () => { throw new Error('stale local epoch'); },
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
expect(out.state.versions.has('game:g1')).toBe(false);
|
|
154
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
155
|
+
expect(out.log.filter((e) => e.kind === 'ssp.ingest')).toHaveLength(0);
|
|
156
|
+
expect(out.emitted).toContainEqual({
|
|
157
|
+
type: 'log',
|
|
158
|
+
level: 'debug',
|
|
159
|
+
message: 'inline live body not landed; the fetch path will pull it',
|
|
160
|
+
data: { ids: ['game:g1'] },
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('writes one copy when the same row arrives for several views', async () => {
|
|
165
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
166
|
+
const out = await runPure(liveChange(env, ['a'], [row(7), row(7), row(8)]), { state: s, handlers });
|
|
167
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(1);
|
|
168
|
+
expect(out.state.versions.get('game:g1')).toBe(8);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('behaves exactly as before when no body rode along', async () => {
|
|
172
|
+
const s = buildState([buildEntry({ def: { hash: 'a' } })]);
|
|
173
|
+
const out = await runPure(liveChange(env, ['a']), { state: s, handlers });
|
|
174
|
+
expect(out.log.filter((e) => e.kind === 'local.execute')).toHaveLength(0);
|
|
175
|
+
expect(out.state.versions.size).toBe(0);
|
|
176
|
+
expect([...out.state.membershipDirty]).toEqual(['a']);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { InlineRow, QueryHash } from '../types';
|
|
2
|
+
import type { Saga } from '../kernel/saga';
|
|
3
|
+
import { fx } from '../kernel/effects';
|
|
4
|
+
import type { ClientState } from '../state/client-state';
|
|
5
|
+
import * as R from '../state/reducers';
|
|
6
|
+
import type { SagaEnv } from '../query/env';
|
|
7
|
+
import { listRefTable } from '../query/env';
|
|
8
|
+
import { markMembershipDirty } from '../query/membership.saga';
|
|
9
|
+
import { landChunk } from '../query/fetch.saga';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* LIVE on the session's `_00_list_ref` table. An event marks its query's
|
|
13
|
+
* membership dirty and the batched re-read decides; followers get the same
|
|
14
|
+
* dirt relayed by the leader.
|
|
15
|
+
*
|
|
16
|
+
* When the subscription was opened with the body joined on
|
|
17
|
+
* (`liveInlineBodies`) the event ALSO carries the changed row. That is an
|
|
18
|
+
* optimisation on top, never a source of truth: the row is landed exactly as a
|
|
19
|
+
* fetched body would be, so a notification the server drops costs nothing but
|
|
20
|
+
* the round trip it would have saved.
|
|
21
|
+
*/
|
|
22
|
+
export function* liveStart(env: SagaEnv): Saga<void> {
|
|
23
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
24
|
+
if (state.tabRole === 'follower') return;
|
|
25
|
+
const table = listRefTable(env, state);
|
|
26
|
+
if (state.sync.liveUuid && state.sync.liveTable === table) return;
|
|
27
|
+
if (state.sync.liveUuid && state.sync.health.connection === 'connected') {
|
|
28
|
+
try {
|
|
29
|
+
yield fx.remote.kill(state.sync.liveUuid);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
yield fx.emit({ type: 'log', level: 'debug', message: 'kill of the previous live query failed', data: { error } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
yield fx.state.update(R.patchSync({ liveUuid: null, liveTable: null }));
|
|
35
|
+
try {
|
|
36
|
+
const uuid = (yield fx.remote.live(table)) as string;
|
|
37
|
+
yield fx.state.update(R.patchSync({ liveUuid: uuid, liveTable: table }));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
yield fx.emit({ type: 'log', level: 'warn', message: 'live subscription failed; the poll covers membership', data: { table, error } });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The socket dropped: the server-side live query is gone with it. */
|
|
44
|
+
export function* liveInvalidate(): Saga<void> {
|
|
45
|
+
yield fx.state.update(R.patchSync({ liveUuid: null, liveTable: null }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One or more edges of these queries changed on the server. */
|
|
49
|
+
export function* liveChange(env: SagaEnv, hashes: QueryHash[], rows?: InlineRow[]): Saga<void> {
|
|
50
|
+
const [known, role, streak] = (yield fx.state.read((s) => [hashes.filter((h) => s.queries.has(h)), s.tabRole, s.sync.pollIdleStreak])) as [
|
|
51
|
+
QueryHash[],
|
|
52
|
+
ClientState['tabRole'],
|
|
53
|
+
number,
|
|
54
|
+
];
|
|
55
|
+
if (known.length === 0) return;
|
|
56
|
+
// Zeroing the streak only decides the delay the NEXT tick picks: `pollTick`
|
|
57
|
+
// arms its timer at the end of a tick, so a page that had coasted up to the
|
|
58
|
+
// 5s cap still waited out that armed timer. When the poll is the thing that
|
|
59
|
+
// catches a change (a notification the server dropped, a row LIVE never
|
|
60
|
+
// carried) that made the worst case 5s even though LIVE had just proved the
|
|
61
|
+
// page was active. Re-arm at the base cadence too, so the safety net is back
|
|
62
|
+
// under us immediately.
|
|
63
|
+
//
|
|
64
|
+
// Only when the poll had actually backed off. A `set` on the same key
|
|
65
|
+
// replaces the pending timer, so re-arming on every event while LIVE is
|
|
66
|
+
// delivering faster than `pollBaseMs` would push the poll out forever and
|
|
67
|
+
// starve the full-membership reconciliation it exists to provide. A streak
|
|
68
|
+
// of 0 means the cadence is already base and there is nothing to correct.
|
|
69
|
+
if (streak > 0) {
|
|
70
|
+
yield fx.state.update(R.patchSync({ pollIdleStreak: 0 }));
|
|
71
|
+
yield fx.timer.set('poll', env.pollBaseMs, { type: 'PollTick' });
|
|
72
|
+
}
|
|
73
|
+
// Before marking membership dirty: recording the version here is what makes
|
|
74
|
+
// the re-read's `planFetch` find nothing left to pull for this row.
|
|
75
|
+
if (rows && rows.length > 0) yield* landInlineRows(env, rows);
|
|
76
|
+
yield* markMembershipDirty(known);
|
|
77
|
+
if (role === 'leader') yield fx.emit({ type: 'tabs:broadcast', message: { type: 'membership-dirty', hashes: known } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Land bodies that rode in on the notification. Rows already held at this
|
|
82
|
+
* version or newer are dropped: one edit bumps the edge in every view holding
|
|
83
|
+
* the row, so the same body arrives once per view.
|
|
84
|
+
*/
|
|
85
|
+
function* landInlineRows(env: SagaEnv, rows: InlineRow[]): Saga<void> {
|
|
86
|
+
const state = (yield fx.state.read((s) => s)) as ClientState;
|
|
87
|
+
const fresh = new Map<string, InlineRow>();
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
if ((state.versions.get(row.id) ?? -1) >= row.version) continue;
|
|
90
|
+
const held = fresh.get(row.id);
|
|
91
|
+
if (!held || held.version < row.version) fresh.set(row.id, row);
|
|
92
|
+
}
|
|
93
|
+
if (fresh.size === 0) return;
|
|
94
|
+
const picked = [...fresh.values()];
|
|
95
|
+
const epoch = (yield fx.local.epoch()) as number;
|
|
96
|
+
const landed = yield* landChunk(
|
|
97
|
+
env,
|
|
98
|
+
picked.map((r) => r.id),
|
|
99
|
+
picked.map((r) => r.record),
|
|
100
|
+
new Map(picked.map((r) => [r.id, r.version] as const)),
|
|
101
|
+
state,
|
|
102
|
+
epoch
|
|
103
|
+
);
|
|
104
|
+
if (!landed) {
|
|
105
|
+
yield fx.emit({ type: 'log', level: 'debug', message: 'inline live body not landed; the fetch path will pull it', data: { ids: picked.map((r) => r.id) } });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nextHealth, selfHealDelayMs } from './policy';
|
|
3
|
+
import { initialHealth } from '../state/client-state';
|
|
4
|
+
import { sameHealth } from '../state/reducers';
|
|
5
|
+
|
|
6
|
+
const start = { health: initialHealth('connected'), consecutiveFailures: 0, hasSyncedOnce: false };
|
|
7
|
+
|
|
8
|
+
describe('nextHealth', () => {
|
|
9
|
+
it('disabled reporting never changes anything', () => {
|
|
10
|
+
const out = nextHealth(start, false, new Error('x'), 0);
|
|
11
|
+
expect(out).toMatchObject({ ...start, degradedNow: false, recoveredNow: false });
|
|
12
|
+
});
|
|
13
|
+
it('first success latches everConnected and hasSyncedOnce', () => {
|
|
14
|
+
const out = nextHealth(start, true, undefined, 3);
|
|
15
|
+
expect(out.health.everConnected).toBe(true);
|
|
16
|
+
expect(out.hasSyncedOnce).toBe(true);
|
|
17
|
+
// A second success moves nothing, so the runtime must not re-notify.
|
|
18
|
+
const again = nextHealth(out, true, undefined, 3);
|
|
19
|
+
expect(sameHealth(again.health, out.health)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it('failures accumulate, degrade at the threshold once, recover on success', () => {
|
|
22
|
+
let s = nextHealth(start, false, new Error('socket closed'), 3);
|
|
23
|
+
expect(s.health.status).toBe('healthy');
|
|
24
|
+
expect(s.consecutiveFailures).toBe(1);
|
|
25
|
+
expect(s.health.kind).toBe('network');
|
|
26
|
+
s = nextHealth(s, false, 'permission denied', 3);
|
|
27
|
+
expect(s.health.kind).toBe('application');
|
|
28
|
+
s = nextHealth(s, false, new Error('timeout'), 3);
|
|
29
|
+
expect(s.health.status).toBe('degraded');
|
|
30
|
+
expect(s.degradedNow).toBe(true);
|
|
31
|
+
expect(s.health.consecutiveFailures).toBe(3);
|
|
32
|
+
s = nextHealth(s, false, new Error('timeout'), 3);
|
|
33
|
+
expect(s.degradedNow).toBe(false);
|
|
34
|
+
expect(s.consecutiveFailures).toBe(4);
|
|
35
|
+
const r = nextHealth(s, true, undefined, 3);
|
|
36
|
+
expect(r.health.status).toBe('healthy');
|
|
37
|
+
expect(r.recoveredNow).toBe(true);
|
|
38
|
+
expect(r.health.kind).toBeUndefined();
|
|
39
|
+
expect(r.consecutiveFailures).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
it('a success after sub-threshold failures resets without a recovery flag', () => {
|
|
42
|
+
const s = nextHealth(start, false, new Error('x'), 3);
|
|
43
|
+
const r = nextHealth(s, true, undefined, 3);
|
|
44
|
+
expect(r.recoveredNow).toBe(false);
|
|
45
|
+
expect(r.consecutiveFailures).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('selfHealDelayMs', () => {
|
|
50
|
+
it('doubles from 2s and caps at 30s', () => {
|
|
51
|
+
expect(selfHealDelayMs(0)).toBe(2000);
|
|
52
|
+
expect(selfHealDelayMs(2)).toBe(8000);
|
|
53
|
+
expect(selfHealDelayMs(10)).toBe(30000);
|
|
54
|
+
expect(selfHealDelayMs(100)).toBe(30000);
|
|
55
|
+
});
|
|
56
|
+
});
|