@spooky-sync/core 0.0.1-alpha.1 → 0.0.1-alpha.2
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 +4 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +30 -24
- package/package.json +3 -3
- package/src/client/runtime.test.ts +14 -0
- package/src/client/runtime.ts +11 -0
- package/src/services/database/connection-supervisor.test.ts +29 -0
- package/src/services/database/connection-supervisor.ts +7 -0
- package/src/state/reducers.ts +15 -1
- package/src/sync/connection.saga.test.ts +5 -3
- package/src/sync/connection.saga.ts +0 -1
- package/src/sync/policy.health.test.ts +5 -5
- package/src/sync/policy.ts +2 -7
package/AGENTS.md
CHANGED
|
@@ -16,7 +16,10 @@ schema.gen.ts (typed schema + SURQL_SCHEMA constant)
|
|
|
16
16
|
Sp00kyClient<S> ── facade: every method = one saga run, selector or subscription
|
|
17
17
|
│
|
|
18
18
|
├── Runtime (client/runtime.ts): one immutable ClientState, lanes, timers,
|
|
19
|
-
│ subscriptions, dirty -> materialize scheduling
|
|
19
|
+
│ subscriptions, dirty -> materialize scheduling. `query:status`,
|
|
20
|
+
│ `activity:changed` and `health:changed` are all notified from here,
|
|
21
|
+
│ off state transitions - never emitted by a saga, so a change made
|
|
22
|
+
│ outside a sync round (a socket drop, say) still reaches subscribers
|
|
20
23
|
├── Sagas (query/ mutation/ sync/ boot/): pure generators yielding effects
|
|
21
24
|
└── Interpreter (kernel/interpreter.ts) -> adapters (services/):
|
|
22
25
|
local store · remote socket · SSP wasm circuit · tabs broker · blobs
|
package/dist/index.d.ts
CHANGED
|
@@ -1689,6 +1689,7 @@ declare class Runtime {
|
|
|
1689
1689
|
private readonly scheduledMaterialize;
|
|
1690
1690
|
private readonly timerKeys;
|
|
1691
1691
|
private lastActivity;
|
|
1692
|
+
private lastHealth;
|
|
1692
1693
|
private disposed;
|
|
1693
1694
|
constructor(opts: RuntimeOptions);
|
|
1694
1695
|
get state(): ClientState;
|
package/dist/index.js
CHANGED
|
@@ -1073,8 +1073,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
1073
1073
|
|
|
1074
1074
|
//#endregion
|
|
1075
1075
|
//#region src/modules/devtools/index.ts
|
|
1076
|
-
const CORE_VERSION = "0.0.1-alpha.
|
|
1077
|
-
const WASM_VERSION = "0.0.1-alpha.
|
|
1076
|
+
const CORE_VERSION = "0.0.1-alpha.2";
|
|
1077
|
+
const WASM_VERSION = "0.0.1-alpha.2";
|
|
1078
1078
|
const SURREAL_VERSION = "3.0.3";
|
|
1079
1079
|
var DevToolsService = class DevToolsService {
|
|
1080
1080
|
eventsHistory = [];
|
|
@@ -2119,13 +2119,20 @@ const setConnection = (connection) => (s) => s.sync.health.connection === connec
|
|
|
2119
2119
|
}
|
|
2120
2120
|
}
|
|
2121
2121
|
};
|
|
2122
|
-
|
|
2122
|
+
/**
|
|
2123
|
+
* Shallow value equality over {@link SyncHealth}. `nextHealth` rebuilds the
|
|
2124
|
+
* snapshot on every sync round, so identity alone would report a change each
|
|
2125
|
+
* time; the runtime uses identity to decide whether to notify subscribers, and
|
|
2126
|
+
* this is what keeps that identity stable while nothing actually moved.
|
|
2127
|
+
*/
|
|
2128
|
+
const sameHealth = (a, b) => a.status === b.status && a.consecutiveFailures === b.consecutiveFailures && a.everConnected === b.everConnected && a.connection === b.connection && a.kind === b.kind && a.error === b.error;
|
|
2129
|
+
const setHealth = (health) => (s) => sameHealth(s.sync.health, health) ? s : {
|
|
2123
2130
|
...s,
|
|
2124
2131
|
sync: {
|
|
2125
2132
|
...s.sync,
|
|
2126
2133
|
health
|
|
2127
2134
|
}
|
|
2128
|
-
}
|
|
2135
|
+
};
|
|
2129
2136
|
const patchSync = (patch) => (s) => ({
|
|
2130
2137
|
...s,
|
|
2131
2138
|
sync: {
|
|
@@ -2807,25 +2814,20 @@ function recordVersionArraysEqual(a, b) {
|
|
|
2807
2814
|
function nextHealth(input, ok, error, degradeAfter) {
|
|
2808
2815
|
const base = {
|
|
2809
2816
|
...input,
|
|
2810
|
-
changed: false,
|
|
2811
2817
|
degradedNow: false,
|
|
2812
2818
|
recoveredNow: false
|
|
2813
2819
|
};
|
|
2814
2820
|
if (degradeAfter <= 0) return base;
|
|
2815
2821
|
if (ok) {
|
|
2816
2822
|
const everConnected = true;
|
|
2817
|
-
if (input.consecutiveFailures === 0) {
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
},
|
|
2826
|
-
changed
|
|
2827
|
-
};
|
|
2828
|
-
}
|
|
2823
|
+
if (input.consecutiveFailures === 0) return {
|
|
2824
|
+
...base,
|
|
2825
|
+
hasSyncedOnce: true,
|
|
2826
|
+
health: {
|
|
2827
|
+
...input.health,
|
|
2828
|
+
everConnected
|
|
2829
|
+
}
|
|
2830
|
+
};
|
|
2829
2831
|
const recovered = input.health.status === "degraded";
|
|
2830
2832
|
return {
|
|
2831
2833
|
...base,
|
|
@@ -2838,7 +2840,6 @@ function nextHealth(input, ok, error, degradeAfter) {
|
|
|
2838
2840
|
error: void 0,
|
|
2839
2841
|
everConnected
|
|
2840
2842
|
},
|
|
2841
|
-
changed: true,
|
|
2842
2843
|
recoveredNow: recovered
|
|
2843
2844
|
};
|
|
2844
2845
|
}
|
|
@@ -2855,7 +2856,6 @@ function nextHealth(input, ok, error, degradeAfter) {
|
|
|
2855
2856
|
kind,
|
|
2856
2857
|
error: message
|
|
2857
2858
|
},
|
|
2858
|
-
changed: true,
|
|
2859
2859
|
degradedNow: true
|
|
2860
2860
|
};
|
|
2861
2861
|
return {
|
|
@@ -5145,10 +5145,6 @@ function* syncOutcome(env, ok, error) {
|
|
|
5145
5145
|
consecutiveFailures: next.consecutiveFailures,
|
|
5146
5146
|
hasSyncedOnce: next.hasSyncedOnce
|
|
5147
5147
|
})));
|
|
5148
|
-
if (next.changed) yield fx.emit({
|
|
5149
|
-
type: "health:changed",
|
|
5150
|
-
health: next.health
|
|
5151
|
-
});
|
|
5152
5148
|
if (next.degradedNow) {
|
|
5153
5149
|
yield fx.state.update(patchSync({ selfHealAttempts: 0 }));
|
|
5154
5150
|
yield fx.timer.set("heal", selfHealDelayMs(0), { type: "SelfHealTick" });
|
|
@@ -5633,11 +5629,13 @@ var Runtime = class {
|
|
|
5633
5629
|
fetching: 0,
|
|
5634
5630
|
pending: 0
|
|
5635
5631
|
};
|
|
5632
|
+
lastHealth;
|
|
5636
5633
|
disposed = false;
|
|
5637
5634
|
constructor(opts) {
|
|
5638
5635
|
this.env = opts.env;
|
|
5639
5636
|
this.logger = opts.logger;
|
|
5640
5637
|
this.stateValue = opts.initialState ?? emptyState({ tabId: opts.tabId });
|
|
5638
|
+
this.lastHealth = this.stateValue.sync.health;
|
|
5641
5639
|
const timers = opts.adapters.timers;
|
|
5642
5640
|
this.adapters = {
|
|
5643
5641
|
...opts.adapters,
|
|
@@ -5752,6 +5750,13 @@ var Runtime = class {
|
|
|
5752
5750
|
...activity
|
|
5753
5751
|
});
|
|
5754
5752
|
}
|
|
5753
|
+
if (next.sync.health !== this.lastHealth) {
|
|
5754
|
+
this.lastHealth = next.sync.health;
|
|
5755
|
+
this.notify({
|
|
5756
|
+
type: "health:changed",
|
|
5757
|
+
health: next.sync.health
|
|
5758
|
+
});
|
|
5759
|
+
}
|
|
5755
5760
|
}
|
|
5756
5761
|
waitFor(until, signal) {
|
|
5757
5762
|
try {
|
|
@@ -6894,6 +6899,7 @@ var ConnectionSupervisor = class ConnectionSupervisor {
|
|
|
6894
6899
|
try {
|
|
6895
6900
|
await withTimeout(this.remote.query("RETURN true"), this.config.heartbeatTimeoutMs, `Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`);
|
|
6896
6901
|
this.heartbeatFailures = 0;
|
|
6902
|
+
this.setState("connected");
|
|
6897
6903
|
this.startHeartbeat();
|
|
6898
6904
|
} catch (err) {
|
|
6899
6905
|
this.heartbeatFailures++;
|
|
@@ -12467,7 +12473,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
12467
12473
|
return new TabsCoordinator({
|
|
12468
12474
|
tabId: s.tabId,
|
|
12469
12475
|
fingerprint: computeTabsFingerprint({
|
|
12470
|
-
coreVersion: "0.0.1-alpha.
|
|
12476
|
+
coreVersion: "0.0.1-alpha.2",
|
|
12471
12477
|
schemaHash: hash53(config.schemaSurql),
|
|
12472
12478
|
endpoint: config.database.endpoint ?? "",
|
|
12473
12479
|
namespace: config.database.namespace,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@spooky-sync/query-builder": "0.0.1-alpha.
|
|
67
|
-
"@spooky-sync/ssp-wasm": "0.0.1-alpha.
|
|
66
|
+
"@spooky-sync/query-builder": "0.0.1-alpha.2",
|
|
67
|
+
"@spooky-sync/ssp-wasm": "0.0.1-alpha.2",
|
|
68
68
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
69
69
|
"@surrealdb/wasm": "^3.0.3",
|
|
70
70
|
"blurhash": "^2.0.5",
|
|
@@ -111,6 +111,20 @@ describe('Runtime state hooks', () => {
|
|
|
111
111
|
rt.subscribeAuthority('zz', () => missing.push('x'), { immediate: true });
|
|
112
112
|
expect(missing).toEqual([]);
|
|
113
113
|
});
|
|
114
|
+
it('notifies health on a transport change, and only on a real move', () => {
|
|
115
|
+
const { rt } = make();
|
|
116
|
+
const seen: string[] = [];
|
|
117
|
+
rt.on('health:changed', (e) => seen.push(e.type === 'health:changed' ? e.health.connection : '?'));
|
|
118
|
+
rt.update(R.setConnection('connected'));
|
|
119
|
+
rt.update(R.setConnection('connected'));
|
|
120
|
+
rt.update(R.setConnection('reconnecting'));
|
|
121
|
+
expect(seen).toEqual(['connected', 'reconnecting']);
|
|
122
|
+
// A health snapshot rebuilt with the same values must not re-notify.
|
|
123
|
+
rt.update(R.setHealth({ ...rt.state.sync.health }));
|
|
124
|
+
expect(seen).toHaveLength(2);
|
|
125
|
+
rt.update(R.setHealth({ ...rt.state.sync.health, status: 'degraded' }));
|
|
126
|
+
expect(seen).toHaveLength(3);
|
|
127
|
+
});
|
|
114
128
|
it('activity, listeners, records subscription bookkeeping, waitFor and dispose', async () => {
|
|
115
129
|
const { a, rt } = make({}, buildState([buildEntry({ def: { hash: 'q' } })]));
|
|
116
130
|
const events: string[] = [];
|
package/src/client/runtime.ts
CHANGED
|
@@ -49,12 +49,14 @@ export class Runtime {
|
|
|
49
49
|
private readonly scheduledMaterialize = new Set<QueryHash>();
|
|
50
50
|
private readonly timerKeys = new Set<string>();
|
|
51
51
|
private lastActivity = { fetching: 0, pending: 0 };
|
|
52
|
+
private lastHealth: ClientState['sync']['health'];
|
|
52
53
|
private disposed = false;
|
|
53
54
|
|
|
54
55
|
constructor(opts: RuntimeOptions) {
|
|
55
56
|
this.env = opts.env;
|
|
56
57
|
this.logger = opts.logger;
|
|
57
58
|
this.stateValue = opts.initialState ?? emptyState({ tabId: opts.tabId });
|
|
59
|
+
this.lastHealth = this.stateValue.sync.health;
|
|
58
60
|
const timers = opts.adapters.timers;
|
|
59
61
|
this.adapters = {
|
|
60
62
|
...opts.adapters,
|
|
@@ -161,6 +163,15 @@ export class Runtime {
|
|
|
161
163
|
this.lastActivity = activity;
|
|
162
164
|
this.notify({ type: 'activity:changed', ...activity });
|
|
163
165
|
}
|
|
166
|
+
// Health is notified from here, not from the saga that folds a sync round:
|
|
167
|
+
// `connection` moves through `setConnection` on a transport event with no
|
|
168
|
+
// round attached, and a subscriber that only heard the round would show a
|
|
169
|
+
// stale socket state forever. `setHealth` keeps the identity stable while
|
|
170
|
+
// the values are unchanged, so this fires exactly on real transitions.
|
|
171
|
+
if (next.sync.health !== this.lastHealth) {
|
|
172
|
+
this.lastHealth = next.sync.health;
|
|
173
|
+
this.notify({ type: 'health:changed', health: next.sync.health });
|
|
174
|
+
}
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
waitFor(until: (s: ClientState) => boolean, signal?: AbortSignal): Promise<void> {
|
|
@@ -229,6 +229,35 @@ describe('ConnectionSupervisor', () => {
|
|
|
229
229
|
sup.dispose();
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
it('un-sticks the forced offline state when the SDK slept through the outage', async () => {
|
|
233
|
+
const listeners = new Map<string, Array<() => void>>();
|
|
234
|
+
vi.stubGlobal('window', {
|
|
235
|
+
addEventListener: (e: string, cb: () => void) => {
|
|
236
|
+
listeners.set(e, [...(listeners.get(e) ?? []), cb]);
|
|
237
|
+
},
|
|
238
|
+
removeEventListener: () => {},
|
|
239
|
+
});
|
|
240
|
+
const { remote, emit } = makeRemote();
|
|
241
|
+
const sup = makeSupervisor(remote);
|
|
242
|
+
emit('connected');
|
|
243
|
+
sup.start();
|
|
244
|
+
|
|
245
|
+
// The browser reports offline, but the SDK's own socket never notices: its
|
|
246
|
+
// status stays `connected` throughout, so it emits no transition at all.
|
|
247
|
+
listeners.get('offline')?.forEach((cb) => cb());
|
|
248
|
+
expect(sup.connection).toBe('disconnected');
|
|
249
|
+
|
|
250
|
+
// Back online. Nothing but the probe can correct the forced state here -
|
|
251
|
+
// there is no SDK event coming - and traffic flowing under a permanent
|
|
252
|
+
// "offline" indicator is exactly the bug.
|
|
253
|
+
listeners.get('online')?.forEach((cb) => cb());
|
|
254
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
255
|
+
expect(remote.query).toHaveBeenCalled();
|
|
256
|
+
expect(sup.connection).toBe('connected');
|
|
257
|
+
|
|
258
|
+
sup.dispose();
|
|
259
|
+
});
|
|
260
|
+
|
|
232
261
|
it('probes immediately when a hidden tab becomes visible', async () => {
|
|
233
262
|
const listeners = new Map<string, Array<() => void>>();
|
|
234
263
|
vi.stubGlobal('document', {
|
|
@@ -286,6 +286,13 @@ export class ConnectionSupervisor {
|
|
|
286
286
|
`Heartbeat timed out after ${this.config.heartbeatTimeoutMs}ms`
|
|
287
287
|
);
|
|
288
288
|
this.heartbeatFailures = 0;
|
|
289
|
+
// A round trip is the strongest evidence there is that the socket works,
|
|
290
|
+
// so it also RECONCILES the reported state. The `offline` handler forces
|
|
291
|
+
// `disconnected` without the SDK's agreement; if the SDK never noticed
|
|
292
|
+
// the outage it emits no transition when the network returns, and that
|
|
293
|
+
// forced state would otherwise stick for the life of the page - traffic
|
|
294
|
+
// flowing while every indicator says offline.
|
|
295
|
+
this.setState('connected');
|
|
289
296
|
this.startHeartbeat();
|
|
290
297
|
} catch (err) {
|
|
291
298
|
this.heartbeatFailures++;
|
package/src/state/reducers.ts
CHANGED
|
@@ -447,9 +447,23 @@ export const setConnection =
|
|
|
447
447
|
(s) =>
|
|
448
448
|
s.sync.health.connection === connection ? s : { ...s, sync: { ...s.sync, health: { ...s.sync.health, connection } } };
|
|
449
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Shallow value equality over {@link SyncHealth}. `nextHealth` rebuilds the
|
|
452
|
+
* snapshot on every sync round, so identity alone would report a change each
|
|
453
|
+
* time; the runtime uses identity to decide whether to notify subscribers, and
|
|
454
|
+
* this is what keeps that identity stable while nothing actually moved.
|
|
455
|
+
*/
|
|
456
|
+
export const sameHealth = (a: SyncHealth, b: SyncHealth): boolean =>
|
|
457
|
+
a.status === b.status &&
|
|
458
|
+
a.consecutiveFailures === b.consecutiveFailures &&
|
|
459
|
+
a.everConnected === b.everConnected &&
|
|
460
|
+
a.connection === b.connection &&
|
|
461
|
+
a.kind === b.kind &&
|
|
462
|
+
a.error === b.error;
|
|
463
|
+
|
|
450
464
|
export const setHealth =
|
|
451
465
|
(health: SyncHealth): Reducer =>
|
|
452
|
-
(s) => ({ ...s, sync: { ...s.sync, health } });
|
|
466
|
+
(s) => (sameHealth(s.sync.health, health) ? s : { ...s, sync: { ...s.sync, health } });
|
|
453
467
|
|
|
454
468
|
export const patchSync =
|
|
455
469
|
(patch: Partial<Omit<ClientState['sync'], 'health'>>): Reducer =>
|
|
@@ -44,15 +44,17 @@ describe('syncOutcome', () => {
|
|
|
44
44
|
expect(state.sync.health.status).toBe('healthy');
|
|
45
45
|
const third = await runPure(syncOutcome(env, false, new Error('socket')), { state });
|
|
46
46
|
expect(third.state.sync.health.status).toBe('degraded');
|
|
47
|
-
|
|
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([]);
|
|
48
50
|
expect(third.timers.get('heal')).toEqual({ ms: 2000, event: { type: 'SelfHealTick' } });
|
|
49
51
|
expect(third.state.sync.selfHealAttempts).toBe(0);
|
|
50
52
|
const back = await runPure(syncOutcome(env, true), { state: third.state });
|
|
51
53
|
expect(back.state.sync.health.status).toBe('healthy');
|
|
52
|
-
expect(back.
|
|
54
|
+
expect(back.state.sync.health).not.toBe(third.state.sync.health);
|
|
53
55
|
expect(back.log.some((e) => e.kind === 'timer.clear' && (e as any).key === 'heal')).toBe(true);
|
|
54
56
|
const quiet = await runPure(syncOutcome(env, true), { state: back.state });
|
|
55
|
-
expect(quiet.
|
|
57
|
+
expect(quiet.state.sync.health).toBe(back.state.sync.health);
|
|
56
58
|
});
|
|
57
59
|
});
|
|
58
60
|
|
|
@@ -50,7 +50,6 @@ export function* syncOutcome(env: SagaEnv, ok: boolean, error?: unknown): Saga<v
|
|
|
50
50
|
yield fx.state.update(
|
|
51
51
|
R.compose(R.setHealth(next.health), R.patchSync({ consecutiveFailures: next.consecutiveFailures, hasSyncedOnce: next.hasSyncedOnce }))
|
|
52
52
|
);
|
|
53
|
-
if (next.changed) yield fx.emit({ type: 'health:changed', health: next.health });
|
|
54
53
|
if (next.degradedNow) {
|
|
55
54
|
yield fx.state.update(R.patchSync({ selfHealAttempts: 0 }));
|
|
56
55
|
yield fx.timer.set('heal', selfHealDelayMs(0), { type: 'SelfHealTick' });
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { nextHealth, selfHealDelayMs } from './policy';
|
|
3
3
|
import { initialHealth } from '../state/client-state';
|
|
4
|
+
import { sameHealth } from '../state/reducers';
|
|
4
5
|
|
|
5
6
|
const start = { health: initialHealth('connected'), consecutiveFailures: 0, hasSyncedOnce: false };
|
|
6
7
|
|
|
7
8
|
describe('nextHealth', () => {
|
|
8
9
|
it('disabled reporting never changes anything', () => {
|
|
9
10
|
const out = nextHealth(start, false, new Error('x'), 0);
|
|
10
|
-
expect(out).toMatchObject({ ...start,
|
|
11
|
+
expect(out).toMatchObject({ ...start, degradedNow: false, recoveredNow: false });
|
|
11
12
|
});
|
|
12
13
|
it('first success latches everConnected and hasSyncedOnce', () => {
|
|
13
14
|
const out = nextHealth(start, true, undefined, 3);
|
|
14
15
|
expect(out.health.everConnected).toBe(true);
|
|
15
16
|
expect(out.hasSyncedOnce).toBe(true);
|
|
16
|
-
|
|
17
|
+
// A second success moves nothing, so the runtime must not re-notify.
|
|
17
18
|
const again = nextHealth(out, true, undefined, 3);
|
|
18
|
-
expect(again.
|
|
19
|
+
expect(sameHealth(again.health, out.health)).toBe(true);
|
|
19
20
|
});
|
|
20
21
|
it('failures accumulate, degrade at the threshold once, recover on success', () => {
|
|
21
22
|
let s = nextHealth(start, false, new Error('socket closed'), 3);
|
|
@@ -30,7 +31,6 @@ describe('nextHealth', () => {
|
|
|
30
31
|
expect(s.health.consecutiveFailures).toBe(3);
|
|
31
32
|
s = nextHealth(s, false, new Error('timeout'), 3);
|
|
32
33
|
expect(s.degradedNow).toBe(false);
|
|
33
|
-
expect(s.changed).toBe(false);
|
|
34
34
|
expect(s.consecutiveFailures).toBe(4);
|
|
35
35
|
const r = nextHealth(s, true, undefined, 3);
|
|
36
36
|
expect(r.health.status).toBe('healthy');
|
|
@@ -42,7 +42,7 @@ describe('nextHealth', () => {
|
|
|
42
42
|
const s = nextHealth(start, false, new Error('x'), 3);
|
|
43
43
|
const r = nextHealth(s, true, undefined, 3);
|
|
44
44
|
expect(r.recoveredNow).toBe(false);
|
|
45
|
-
expect(r.
|
|
45
|
+
expect(r.consecutiveFailures).toBe(0);
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
|
package/src/sync/policy.ts
CHANGED
|
@@ -452,8 +452,6 @@ export interface HealthInput {
|
|
|
452
452
|
}
|
|
453
453
|
|
|
454
454
|
export interface HealthOutput extends HealthInput {
|
|
455
|
-
/** The health changed in a way subscribers must hear about. */
|
|
456
|
-
changed: boolean;
|
|
457
455
|
/** Crossed into degraded on this outcome: start self-heal. */
|
|
458
456
|
degradedNow: boolean;
|
|
459
457
|
/** Left degraded on this outcome: stop self-heal. */
|
|
@@ -466,13 +464,12 @@ export interface HealthOutput extends HealthInput {
|
|
|
466
464
|
* back. `degradeAfter <= 0` disables reporting.
|
|
467
465
|
*/
|
|
468
466
|
export function nextHealth(input: HealthInput, ok: boolean, error: unknown, degradeAfter: number): HealthOutput {
|
|
469
|
-
const base = { ...input,
|
|
467
|
+
const base = { ...input, degradedNow: false, recoveredNow: false };
|
|
470
468
|
if (degradeAfter <= 0) return base;
|
|
471
469
|
if (ok) {
|
|
472
470
|
const everConnected = true;
|
|
473
471
|
if (input.consecutiveFailures === 0) {
|
|
474
|
-
|
|
475
|
-
return { ...base, hasSyncedOnce: true, health: { ...input.health, everConnected }, changed };
|
|
472
|
+
return { ...base, hasSyncedOnce: true, health: { ...input.health, everConnected } };
|
|
476
473
|
}
|
|
477
474
|
const recovered = input.health.status === 'degraded';
|
|
478
475
|
return {
|
|
@@ -480,7 +477,6 @@ export function nextHealth(input: HealthInput, ok: boolean, error: unknown, degr
|
|
|
480
477
|
hasSyncedOnce: true,
|
|
481
478
|
consecutiveFailures: 0,
|
|
482
479
|
health: { ...input.health, status: 'healthy', kind: undefined, error: undefined, everConnected },
|
|
483
|
-
changed: true,
|
|
484
480
|
recoveredNow: recovered,
|
|
485
481
|
};
|
|
486
482
|
}
|
|
@@ -492,7 +488,6 @@ export function nextHealth(input: HealthInput, ok: boolean, error: unknown, degr
|
|
|
492
488
|
...base,
|
|
493
489
|
consecutiveFailures,
|
|
494
490
|
health: { ...input.health, status: 'degraded', consecutiveFailures, kind, error: message },
|
|
495
|
-
changed: true,
|
|
496
491
|
degradedNow: true,
|
|
497
492
|
};
|
|
498
493
|
}
|