@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 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.1";
1077
- const WASM_VERSION = "0.0.1-alpha.1";
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
- const setHealth = (health) => (s) => ({
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
- const changed = !input.health.everConnected;
2819
- return {
2820
- ...base,
2821
- hasSyncedOnce: true,
2822
- health: {
2823
- ...input.health,
2824
- everConnected
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.1",
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.1",
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.1",
67
- "@spooky-sync/ssp-wasm": "0.0.1-alpha.1",
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[] = [];
@@ -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++;
@@ -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
- expect(third.emitted).toEqual([{ type: 'health:changed', health: third.state.sync.health }]);
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.emitted).toHaveLength(1);
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.emitted).toEqual([]);
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, changed: false, degradedNow: false, recoveredNow: false });
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
- expect(out.changed).toBe(true);
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.changed).toBe(false);
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.changed).toBe(true);
45
+ expect(r.consecutiveFailures).toBe(0);
46
46
  });
47
47
  });
48
48
 
@@ -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, changed: false, degradedNow: false, recoveredNow: false };
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
- const changed = !input.health.everConnected;
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
  }