@spooky-sync/core 0.0.1-canary.100 → 0.0.1-canary.102

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/dist/index.d.ts CHANGED
@@ -638,6 +638,7 @@ declare class Sp00kySync<S extends SchemaStructure> {
638
638
  private syncHealthStatus;
639
639
  private lastSyncErrorKind;
640
640
  private lastSyncErrorMessage;
641
+ private hasSyncedOnce;
641
642
  private selfHealTimer;
642
643
  private selfHealAttempts;
643
644
  private static readonly SELF_HEAL_BASE_MS;
@@ -693,6 +694,15 @@ declare class Sp00kySync<S extends SchemaStructure> {
693
694
  * One poll cycle: refetch `_00_list_ref` for every active query. Returns
694
695
  * whether ANY query's remoteArray actually changed — the scheduler uses this
695
696
  * to drive the adaptive idle backoff.
697
+ *
698
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
699
+ * otherwise activity-driven (mutations/registrations via the scheduler,
700
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
701
+ * would linger until the next mutation and a genuine idle drop would be
702
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
703
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
704
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
705
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
696
706
  */
697
707
  private pollListRefForActiveQueries;
698
708
  /**
package/dist/index.js CHANGED
@@ -110,6 +110,8 @@ function parseValue(name, column, value) {
110
110
  //#region src/utils/error-classification.ts
111
111
  const NETWORK_ERROR_PATTERNS = [
112
112
  "connection",
113
+ "must be connected",
114
+ "connectionunavailable",
113
115
  "timeout",
114
116
  "timed out",
115
117
  "websocket",
@@ -2676,6 +2678,7 @@ var Sp00kySync = class Sp00kySync {
2676
2678
  syncHealthStatus = "healthy";
2677
2679
  lastSyncErrorKind;
2678
2680
  lastSyncErrorMessage;
2681
+ hasSyncedOnce = false;
2679
2682
  selfHealTimer = null;
2680
2683
  selfHealAttempts = 0;
2681
2684
  static SELF_HEAL_BASE_MS = 2e3;
@@ -2686,7 +2689,8 @@ var Sp00kySync = class Sp00kySync {
2686
2689
  status: this.syncHealthStatus,
2687
2690
  consecutiveFailures: this.consecutiveSyncFailures,
2688
2691
  kind: this.syncHealthStatus === "degraded" ? this.lastSyncErrorKind : void 0,
2689
- error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0
2692
+ error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0,
2693
+ everConnected: this.hasSyncedOnce
2690
2694
  };
2691
2695
  }
2692
2696
  /**
@@ -2712,6 +2716,7 @@ var Sp00kySync = class Sp00kySync {
2712
2716
  recordSyncOutcome(ok, error) {
2713
2717
  if (this.degradeAfterFailures <= 0) return;
2714
2718
  if (ok) {
2719
+ this.hasSyncedOnce = true;
2715
2720
  if (this.consecutiveSyncFailures === 0) return;
2716
2721
  this.consecutiveSyncFailures = 0;
2717
2722
  if (this.syncHealthStatus !== "healthy") {
@@ -2917,20 +2922,45 @@ var Sp00kySync = class Sp00kySync {
2917
2922
  * One poll cycle: refetch `_00_list_ref` for every active query. Returns
2918
2923
  * whether ANY query's remoteArray actually changed — the scheduler uses this
2919
2924
  * to drive the adaptive idle backoff.
2925
+ *
2926
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
2927
+ * otherwise activity-driven (mutations/registrations via the scheduler,
2928
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
2929
+ * would linger until the next mutation and a genuine idle drop would be
2930
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
2931
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
2932
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
2933
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
2920
2934
  */
2921
2935
  async pollListRefForActiveQueries() {
2922
2936
  const hashes = this.dataModule.getActiveQueryHashes();
2923
- if (hashes.length === 0) return false;
2937
+ if (hashes.length === 0) {
2938
+ try {
2939
+ await this.remote.query("RETURN true");
2940
+ this.recordSyncOutcome(true);
2941
+ } catch (err) {
2942
+ this.recordSyncOutcome(false, err);
2943
+ }
2944
+ return false;
2945
+ }
2924
2946
  let anyChanged = false;
2947
+ let reached = false;
2948
+ let firstNetworkErr;
2925
2949
  for (const hash of hashes) try {
2926
2950
  if (await this.refetchListRefForQuery(hash)) anyChanged = true;
2951
+ reached = true;
2927
2952
  } catch (err) {
2953
+ if (classifySyncError(err) === "network") {
2954
+ if (firstNetworkErr === void 0) firstNetworkErr = err;
2955
+ } else reached = true;
2928
2956
  this.logger.debug({
2929
2957
  err: err?.message ?? err,
2930
2958
  hash,
2931
2959
  Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
2932
2960
  }, "Per-query list_ref poll failed");
2933
2961
  }
2962
+ if (reached) this.recordSyncOutcome(true);
2963
+ else if (firstNetworkErr !== void 0) this.recordSyncOutcome(false, firstNetworkErr);
2934
2964
  return anyChanged;
2935
2965
  }
2936
2966
  /**
@@ -3485,8 +3515,8 @@ function parseBackendInfo(raw) {
3485
3515
 
3486
3516
  //#endregion
3487
3517
  //#region src/modules/devtools/index.ts
3488
- const CORE_VERSION = "0.0.1-canary.100";
3489
- const WASM_VERSION = "0.0.1-canary.100";
3518
+ const CORE_VERSION = "0.0.1-canary.102";
3519
+ const WASM_VERSION = "0.0.1-canary.102";
3490
3520
  const SURREAL_VERSION = "3.0.3";
3491
3521
  var DevToolsService = class {
3492
3522
  eventsHistory = [];
@@ -3494,6 +3524,9 @@ var DevToolsService = class {
3494
3524
  version = CORE_VERSION;
3495
3525
  backendInfo = emptyBackendInfo();
3496
3526
  enabled = false;
3527
+ localTables = [];
3528
+ localTablesFetching = false;
3529
+ localTablesAt = 0;
3497
3530
  constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
3498
3531
  this.databaseService = databaseService;
3499
3532
  this.remoteDatabaseService = remoteDatabaseService;
@@ -3547,6 +3580,7 @@ var DevToolsService = class {
3547
3580
  createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
3548
3581
  lastUpdate: Date.now(),
3549
3582
  updateCount: q.updateCount,
3583
+ ttl: q.config.ttl,
3550
3584
  query: q.config.surql,
3551
3585
  variables: q.config.params || {},
3552
3586
  dataSize: q.records?.length || 0,
@@ -3625,7 +3659,38 @@ var DevToolsService = class {
3625
3659
  });
3626
3660
  if (this.eventsHistory.length > 100) this.eventsHistory.shift();
3627
3661
  }
3662
+ /** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
3663
+ unwrapInfo(res) {
3664
+ if (!Array.isArray(res) || !res[0]) return null;
3665
+ const first = res[0];
3666
+ if (first && typeof first === "object" && "result" in first) return first.result;
3667
+ if (Array.isArray(first)) return first[0];
3668
+ return first;
3669
+ }
3670
+ /**
3671
+ * Refresh the cached full local-table list from `INFO FOR DB`. Fire-and-forget
3672
+ * and throttled — called from getState() so the panel gets every table
3673
+ * (including internal `_00_*`) without running its own (flaky) queries.
3674
+ */
3675
+ refreshLocalTables() {
3676
+ if (this.localTablesFetching) return;
3677
+ if (Date.now() - this.localTablesAt < 3e3) return;
3678
+ this.localTablesFetching = true;
3679
+ this.databaseService.query("INFO FOR DB").then((res) => {
3680
+ const info = this.unwrapInfo(res);
3681
+ this.localTablesAt = Date.now();
3682
+ if (info && info.tables) {
3683
+ const names = Object.keys(info.tables);
3684
+ const changed = names.length !== this.localTables.length || names.some((n, i) => n !== this.localTables[i]);
3685
+ this.localTables = names;
3686
+ if (changed) this.notifyDevTools();
3687
+ }
3688
+ }).catch(() => {}).finally(() => {
3689
+ this.localTablesFetching = false;
3690
+ });
3691
+ }
3628
3692
  getState() {
3693
+ this.refreshLocalTables();
3629
3694
  return this.serializeForDevTools({
3630
3695
  eventsHistory: [...this.eventsHistory],
3631
3696
  activeQueries: Object.fromEntries(this.getActiveQueries()),
@@ -3644,7 +3709,7 @@ var DevToolsService = class {
3644
3709
  entities: this.backendInfo.entities
3645
3710
  },
3646
3711
  database: {
3647
- tables: this.schema.tables.map((t) => t.name),
3712
+ tables: this.localTables.length ? this.localTables : this.schema.tables.map((t) => t.name),
3648
3713
  tableData: {}
3649
3714
  }
3650
3715
  });
package/dist/types.d.ts CHANGED
@@ -340,6 +340,13 @@ interface SyncHealth {
340
340
  kind?: 'network' | 'application';
341
341
  /** Message of the most recent failure (only set while `degraded`). */
342
342
  error?: string;
343
+ /**
344
+ * `true` once at least one sync round has succeeded this session. Lets a UI
345
+ * distinguish a first-time "connecting" phase (never reached the server yet,
346
+ * so a cold-start failure run is expected) from a real lost connection after
347
+ * a working session. Never resets back to `false` once set.
348
+ */
349
+ everConnected: boolean;
343
350
  }
344
351
  type QueryHash = string;
345
352
  type RecordVersionArray = Array<[string, number]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.100",
3
+ "version": "0.0.1-canary.102",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -59,8 +59,8 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@spooky-sync/query-builder": "0.0.1-canary.100",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.100",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.102",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.102",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
@@ -49,6 +49,13 @@ export class DevToolsService implements StreamUpdateReceiver {
49
49
  // (the on-demand GET_STATE pull) still works before the push channel turns on.
50
50
  private enabled = false;
51
51
 
52
+ // Full local table list (incl. internal `_00_*`), enumerated from the local DB
53
+ // via our own reliable `service.query` — the DevTools panel's page-eval query
54
+ // bridge is unreliable under load, so the panel relies on this instead.
55
+ private localTables: string[] = [];
56
+ private localTablesFetching = false;
57
+ private localTablesAt = 0;
58
+
52
59
  constructor(
53
60
  private databaseService: LocalDatabaseService,
54
61
  private remoteDatabaseService: RemoteDatabaseService,
@@ -130,6 +137,7 @@ export class DevToolsService implements StreamUpdateReceiver {
130
137
  : new Date(q.config.lastActiveAt || Date.now()).getTime(),
131
138
  lastUpdate: Date.now(),
132
139
  updateCount: q.updateCount,
140
+ ttl: q.config.ttl,
133
141
  query: q.config.surql,
134
142
  variables: q.config.params || {},
135
143
  dataSize: q.records?.length || 0,
@@ -230,7 +238,50 @@ export class DevToolsService implements StreamUpdateReceiver {
230
238
  if (this.eventsHistory.length > 100) this.eventsHistory.shift();
231
239
  }
232
240
 
241
+ /** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
242
+ private unwrapInfo(res: any): any {
243
+ if (!Array.isArray(res) || !res[0]) return null;
244
+ const first = res[0];
245
+ if (first && typeof first === 'object' && 'result' in first) return first.result;
246
+ if (Array.isArray(first)) return first[0];
247
+ return first;
248
+ }
249
+
250
+ /**
251
+ * Refresh the cached full local-table list from `INFO FOR DB`. Fire-and-forget
252
+ * and throttled — called from getState() so the panel gets every table
253
+ * (including internal `_00_*`) without running its own (flaky) queries.
254
+ */
255
+ private refreshLocalTables(): void {
256
+ if (this.localTablesFetching) return;
257
+ const now = Date.now();
258
+ if (now - this.localTablesAt < 3000) return;
259
+ this.localTablesFetching = true;
260
+ void this.databaseService
261
+ .query<any>('INFO FOR DB')
262
+ .then((res) => {
263
+ const info = this.unwrapInfo(res);
264
+ this.localTablesAt = Date.now();
265
+ if (info && info.tables) {
266
+ const names = Object.keys(info.tables);
267
+ const changed =
268
+ names.length !== this.localTables.length ||
269
+ names.some((n, i) => n !== this.localTables[i]);
270
+ this.localTables = names;
271
+ if (changed) this.notifyDevTools();
272
+ }
273
+ })
274
+ .catch(() => {
275
+ // Ignore — fall back to the declared app schema below.
276
+ })
277
+ .finally(() => {
278
+ this.localTablesFetching = false;
279
+ });
280
+ }
281
+
233
282
  private getState() {
283
+ // Keep the full local-table list fresh (throttled, non-blocking).
284
+ this.refreshLocalTables();
234
285
  return this.serializeForDevTools({
235
286
  eventsHistory: [...this.eventsHistory],
236
287
  activeQueries: Object.fromEntries(this.getActiveQueries()),
@@ -249,7 +300,11 @@ export class DevToolsService implements StreamUpdateReceiver {
249
300
  entities: this.backendInfo.entities,
250
301
  },
251
302
  database: {
252
- tables: this.schema.tables.map((t) => t.name),
303
+ // Prefer the live local-table list (includes internal `_00_*`); fall
304
+ // back to the declared app schema until the first enumeration lands.
305
+ tables: this.localTables.length
306
+ ? this.localTables
307
+ : this.schema.tables.map((t) => t.name),
253
308
  tableData: {},
254
309
  },
255
310
  });
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Sp00kySync } from './sync';
3
+
4
+ // The idle list-ref poll is the only health signal that runs on a quiet page.
5
+ // These tests exercise `pollListRefForActiveQueries` -> `recordSyncOutcome` in
6
+ // isolation: a run of network-failed cycles degrades, and a single clean cycle
7
+ // recovers WITHOUT any mutation (the reported bug). Construction is cheap —
8
+ // Sp00kySync's constructor only stores refs and calls logger.child; no timers or
9
+ // I/O start until init(), which we never call.
10
+
11
+ const CONNECTION_UNAVAILABLE =
12
+ 'You must be connected to a SurrealDB instance before performing this operation';
13
+
14
+ function makeSync(hashes: string[]) {
15
+ const logger: any = {
16
+ child: () => logger,
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ trace: () => {},
22
+ };
23
+ const remote: any = { query: vi.fn().mockResolvedValue([true]) };
24
+ const dataModule: any = { getActiveQueryHashes: () => hashes };
25
+
26
+ const sync = new Sp00kySync(
27
+ {} as any,
28
+ remote,
29
+ {} as any,
30
+ dataModule,
31
+ {} as any,
32
+ logger,
33
+ { degradeAfterConsecutiveFailures: 3 }
34
+ );
35
+
36
+ // `refetchListRefForQuery` is what the poll calls per active hash; stub it so
37
+ // the test controls per-cycle reachability. `poll` invokes the private method.
38
+ const refetch = vi.fn();
39
+ (sync as any).refetchListRefForQuery = refetch;
40
+ const poll = () => (sync as any).pollListRefForActiveQueries() as Promise<boolean>;
41
+
42
+ return { sync, remote, refetch, poll };
43
+ }
44
+
45
+ describe('sync health via idle poll', () => {
46
+ beforeEach(() => vi.clearAllMocks());
47
+
48
+ it('degrades after N consecutive network-failed poll cycles', async () => {
49
+ const { sync, refetch, poll } = makeSync(['h1']);
50
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
51
+
52
+ await poll();
53
+ expect(sync.syncHealth.status).toBe('healthy');
54
+ await poll();
55
+ expect(sync.syncHealth.status).toBe('healthy');
56
+ await poll();
57
+ expect(sync.syncHealth.status).toBe('degraded');
58
+ expect(sync.syncHealth.kind).toBe('network');
59
+ });
60
+
61
+ it('recovers on the next clean poll cycle — no mutation needed', async () => {
62
+ const { sync, refetch, poll } = makeSync(['h1']);
63
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
64
+ await poll();
65
+ await poll();
66
+ await poll();
67
+ expect(sync.syncHealth.status).toBe('degraded');
68
+
69
+ // Connectivity returns; a plain idle poll (no user action) clears it.
70
+ refetch.mockResolvedValue(true);
71
+ await poll();
72
+ expect(sync.syncHealth.status).toBe('healthy');
73
+ });
74
+
75
+ it('does not degrade on application errors (server was reached)', async () => {
76
+ const { sync, refetch, poll } = makeSync(['h1']);
77
+ refetch.mockRejectedValue(new Error('Permission denied'));
78
+ await poll();
79
+ await poll();
80
+ await poll();
81
+ await poll();
82
+ expect(sync.syncHealth.status).toBe('healthy');
83
+ });
84
+
85
+ it('counts a mixed cycle (one reachable hash) as reached', async () => {
86
+ const { sync, refetch, poll } = makeSync(['h1', 'h2']);
87
+ // First degrade via all-network cycles.
88
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
89
+ await poll();
90
+ await poll();
91
+ await poll();
92
+ expect(sync.syncHealth.status).toBe('degraded');
93
+
94
+ // Now one hash succeeds, the other still network-fails → reached → healthy.
95
+ refetch.mockReset();
96
+ refetch
97
+ .mockResolvedValueOnce(true)
98
+ .mockRejectedValueOnce(new Error(CONNECTION_UNAVAILABLE));
99
+ await poll();
100
+ expect(sync.syncHealth.status).toBe('healthy');
101
+ });
102
+
103
+ it('probes RETURN true when there are no active queries', async () => {
104
+ const { sync, remote, poll } = makeSync([]);
105
+ remote.query.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
106
+ await poll();
107
+ await poll();
108
+ await poll();
109
+ expect(remote.query).toHaveBeenCalledWith('RETURN true');
110
+ expect(sync.syncHealth.status).toBe('degraded');
111
+
112
+ remote.query.mockResolvedValue([true]);
113
+ await poll();
114
+ expect(sync.syncHealth.status).toBe('healthy');
115
+ });
116
+
117
+ it('leaves everConnected false through a cold-start failure run', async () => {
118
+ const { sync, refetch, poll } = makeSync(['h1']);
119
+ expect(sync.syncHealth.everConnected).toBe(false);
120
+
121
+ // Server never reached: 3 failed cycles degrade, but this is the initial
122
+ // "connecting" phase, not a lost connection — everConnected stays false.
123
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
124
+ await poll();
125
+ await poll();
126
+ await poll();
127
+ expect(sync.syncHealth.status).toBe('degraded');
128
+ expect(sync.syncHealth.everConnected).toBe(false);
129
+ });
130
+
131
+ it('latches everConnected on the first success and keeps it through a later degrade', async () => {
132
+ const { sync, refetch, poll } = makeSync(['h1']);
133
+
134
+ // First successful round reaches the server: connecting phase is over.
135
+ refetch.mockResolvedValue(true);
136
+ await poll();
137
+ expect(sync.syncHealth.status).toBe('healthy');
138
+ expect(sync.syncHealth.everConnected).toBe(true);
139
+
140
+ // Connection later drops: degraded now reflects a REAL lost connection.
141
+ refetch.mockReset();
142
+ refetch.mockRejectedValue(new Error(CONNECTION_UNAVAILABLE));
143
+ await poll();
144
+ await poll();
145
+ await poll();
146
+ expect(sync.syncHealth.status).toBe('degraded');
147
+ expect(sync.syncHealth.everConnected).toBe(true);
148
+ });
149
+ });
@@ -168,6 +168,10 @@ export class Sp00kySync<S extends SchemaStructure> {
168
168
  private syncHealthStatus: SyncHealthStatus = 'healthy';
169
169
  private lastSyncErrorKind: 'network' | 'application' | undefined;
170
170
  private lastSyncErrorMessage: string | undefined;
171
+ // Latched `true` on the first successful sync round; never reset. Lets a UI
172
+ // tell a cold-start "connecting" phase (never reached the server) apart from
173
+ // a real lost connection after a working session.
174
+ private hasSyncedOnce = false;
171
175
 
172
176
  // Self-heal: while degraded, re-drive sync on an exponential backoff so the
173
177
  // app recovers on its own — even when the socket never actually dropped (in
@@ -186,6 +190,7 @@ export class Sp00kySync<S extends SchemaStructure> {
186
190
  consecutiveFailures: this.consecutiveSyncFailures,
187
191
  kind: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorKind : undefined,
188
192
  error: this.syncHealthStatus === 'degraded' ? this.lastSyncErrorMessage : undefined,
193
+ everConnected: this.hasSyncedOnce,
189
194
  };
190
195
  }
191
196
 
@@ -216,6 +221,9 @@ export class Sp00kySync<S extends SchemaStructure> {
216
221
  private recordSyncOutcome(ok: boolean, error?: unknown): void {
217
222
  if (this.degradeAfterFailures <= 0) return;
218
223
  if (ok) {
224
+ // Latch first-ever success so a UI can drop the connecting phase. Set
225
+ // before the early return so a clean cold start (0 prior failures) counts.
226
+ this.hasSyncedOnce = true;
219
227
  if (this.consecutiveSyncFailures === 0) return;
220
228
  this.consecutiveSyncFailures = 0;
221
229
  if (this.syncHealthStatus !== 'healthy') {
@@ -485,21 +493,63 @@ export class Sp00kySync<S extends SchemaStructure> {
485
493
  * One poll cycle: refetch `_00_list_ref` for every active query. Returns
486
494
  * whether ANY query's remoteArray actually changed — the scheduler uses this
487
495
  * to drive the adaptive idle backoff.
496
+ *
497
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
498
+ * otherwise activity-driven (mutations/registrations via the scheduler,
499
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
500
+ * would linger until the next mutation and a genuine idle drop would be
501
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
502
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
503
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
504
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
488
505
  */
489
506
  private async pollListRefForActiveQueries(): Promise<boolean> {
490
507
  const hashes = this.dataModule.getActiveQueryHashes();
491
- if (hashes.length === 0) return false;
508
+ if (hashes.length === 0) {
509
+ // No active queries to piggyback on, but health still needs a heartbeat —
510
+ // probe connectivity directly so an idle page with no live queries doesn't
511
+ // go blind. Cheap, and gated by the same adaptive backoff (≤5s idle cap).
512
+ try {
513
+ await this.remote.query('RETURN true');
514
+ this.recordSyncOutcome(true);
515
+ } catch (err) {
516
+ this.recordSyncOutcome(false, err);
517
+ }
518
+ return false;
519
+ }
492
520
  let anyChanged = false;
521
+ // `reached` = the server answered at least once this cycle (a success, or an
522
+ // *application* error, which still proves reachability). `firstNetworkErr`
523
+ // holds the first network-classified failure. A cycle that only produced
524
+ // network errors reports the outcome as a down round; a mixed/app cycle counts
525
+ // as reached; an all-application cycle reports nothing (that's a query-shape
526
+ // fault owned by the registration path, not a reachability signal).
527
+ let reached = false;
528
+ let firstNetworkErr: unknown;
493
529
  for (const hash of hashes) {
494
530
  try {
495
531
  if (await this.refetchListRefForQuery(hash)) anyChanged = true;
532
+ reached = true;
496
533
  } catch (err) {
534
+ if (classifySyncError(err) === 'network') {
535
+ if (firstNetworkErr === undefined) firstNetworkErr = err;
536
+ } else {
537
+ reached = true;
538
+ }
497
539
  this.logger.debug(
498
540
  { err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
499
541
  'Per-query list_ref poll failed'
500
542
  );
501
543
  }
502
544
  }
545
+ // Call the private outcome recorder directly rather than routing through the
546
+ // scheduler — the scheduler only reports on rounds that drained ≥1 queue item
547
+ // (`processedAny`), and this isn't a queue round.
548
+ if (reached) {
549
+ this.recordSyncOutcome(true);
550
+ } else if (firstNetworkErr !== undefined) {
551
+ this.recordSyncOutcome(false, firstNetworkErr);
552
+ }
503
553
  return anyChanged;
504
554
  }
505
555
 
package/src/types.ts CHANGED
@@ -193,6 +193,13 @@ export interface SyncHealth {
193
193
  kind?: 'network' | 'application';
194
194
  /** Message of the most recent failure (only set while `degraded`). */
195
195
  error?: string;
196
+ /**
197
+ * `true` once at least one sync round has succeeded this session. Lets a UI
198
+ * distinguish a first-time "connecting" phase (never reached the server yet,
199
+ * so a cold-start failure run is expected) from a real lost connection after
200
+ * a working session. Never resets back to `false` once set.
201
+ */
202
+ everConnected: boolean;
196
203
  }
197
204
 
198
205
  export type QueryHash = string;
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { classifySyncError } from './error-classification';
3
+
4
+ describe('classifySyncError', () => {
5
+ it('classifies surreal ConnectionUnavailableError as network', () => {
6
+ // Thrown synchronously by the WS engine's send() while the socket is down
7
+ // but reconnect hasn't fired. Message contains "connected", not "connection".
8
+ const err = new Error(
9
+ 'You must be connected to a SurrealDB instance before performing this operation'
10
+ );
11
+ expect(classifySyncError(err)).toBe('network');
12
+ });
13
+
14
+ it('classifies surreal CallTerminatedError as network', () => {
15
+ const err = new Error(
16
+ 'The call has been terminated because the connection was closed'
17
+ );
18
+ expect(classifySyncError(err)).toBe('network');
19
+ });
20
+
21
+ it.each([
22
+ 'WebSocket connection failed',
23
+ 'fetch failed',
24
+ 'connect ECONNREFUSED 127.0.0.1:8666',
25
+ 'request timed out',
26
+ 'The operation was aborted',
27
+ 'socket hang up',
28
+ ])('classifies %j as network', (message) => {
29
+ expect(classifySyncError(new Error(message))).toBe('network');
30
+ });
31
+
32
+ it.each([
33
+ 'Permission denied',
34
+ 'There was a problem with the database: record already exists',
35
+ 'Parse error: unexpected token',
36
+ ])('classifies %j as application', (message) => {
37
+ expect(classifySyncError(new Error(message))).toBe('application');
38
+ });
39
+
40
+ it('handles non-Error values', () => {
41
+ expect(classifySyncError('connection reset')).toBe('network');
42
+ expect(classifySyncError('boom')).toBe('application');
43
+ });
44
+ });
@@ -1,5 +1,12 @@
1
1
  const NETWORK_ERROR_PATTERNS = [
2
2
  'connection',
3
+ // surreal's ConnectionUnavailableError reads "You must be connected to a
4
+ // SurrealDB instance..." — it contains "connected", not "connection", so it
5
+ // slips past the pattern above. The WS engine throws it synchronously from
6
+ // `send()` while the socket is down but reconnect hasn't fired yet, so it's the
7
+ // canonical error on an idle-dropped socket; classify it as network.
8
+ 'must be connected',
9
+ 'connectionunavailable',
3
10
  'timeout',
4
11
  'timed out',
5
12
  'websocket',