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

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
@@ -693,6 +693,15 @@ declare class Sp00kySync<S extends SchemaStructure> {
693
693
  * One poll cycle: refetch `_00_list_ref` for every active query. Returns
694
694
  * whether ANY query's remoteArray actually changed — the scheduler uses this
695
695
  * to drive the adaptive idle backoff.
696
+ *
697
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
698
+ * otherwise activity-driven (mutations/registrations via the scheduler,
699
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
700
+ * would linger until the next mutation and a genuine idle drop would be
701
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
702
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
703
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
704
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
696
705
  */
697
706
  private pollListRefForActiveQueries;
698
707
  /**
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",
@@ -2917,20 +2919,45 @@ var Sp00kySync = class Sp00kySync {
2917
2919
  * One poll cycle: refetch `_00_list_ref` for every active query. Returns
2918
2920
  * whether ANY query's remoteArray actually changed — the scheduler uses this
2919
2921
  * to drive the adaptive idle backoff.
2922
+ *
2923
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
2924
+ * otherwise activity-driven (mutations/registrations via the scheduler,
2925
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
2926
+ * would linger until the next mutation and a genuine idle drop would be
2927
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
2928
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
2929
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
2930
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
2920
2931
  */
2921
2932
  async pollListRefForActiveQueries() {
2922
2933
  const hashes = this.dataModule.getActiveQueryHashes();
2923
- if (hashes.length === 0) return false;
2934
+ if (hashes.length === 0) {
2935
+ try {
2936
+ await this.remote.query("RETURN true");
2937
+ this.recordSyncOutcome(true);
2938
+ } catch (err) {
2939
+ this.recordSyncOutcome(false, err);
2940
+ }
2941
+ return false;
2942
+ }
2924
2943
  let anyChanged = false;
2944
+ let reached = false;
2945
+ let firstNetworkErr;
2925
2946
  for (const hash of hashes) try {
2926
2947
  if (await this.refetchListRefForQuery(hash)) anyChanged = true;
2948
+ reached = true;
2927
2949
  } catch (err) {
2950
+ if (classifySyncError(err) === "network") {
2951
+ if (firstNetworkErr === void 0) firstNetworkErr = err;
2952
+ } else reached = true;
2928
2953
  this.logger.debug({
2929
2954
  err: err?.message ?? err,
2930
2955
  hash,
2931
2956
  Category: "sp00ky-client::Sp00kySync::pollListRefForActiveQueries"
2932
2957
  }, "Per-query list_ref poll failed");
2933
2958
  }
2959
+ if (reached) this.recordSyncOutcome(true);
2960
+ else if (firstNetworkErr !== void 0) this.recordSyncOutcome(false, firstNetworkErr);
2934
2961
  return anyChanged;
2935
2962
  }
2936
2963
  /**
@@ -3485,8 +3512,8 @@ function parseBackendInfo(raw) {
3485
3512
 
3486
3513
  //#endregion
3487
3514
  //#region src/modules/devtools/index.ts
3488
- const CORE_VERSION = "0.0.1-canary.100";
3489
- const WASM_VERSION = "0.0.1-canary.100";
3515
+ const CORE_VERSION = "0.0.1-canary.101";
3516
+ const WASM_VERSION = "0.0.1-canary.101";
3490
3517
  const SURREAL_VERSION = "3.0.3";
3491
3518
  var DevToolsService = class {
3492
3519
  eventsHistory = [];
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.101",
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.101",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.101",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
@@ -0,0 +1,116 @@
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
+ });
@@ -485,21 +485,63 @@ export class Sp00kySync<S extends SchemaStructure> {
485
485
  * One poll cycle: refetch `_00_list_ref` for every active query. Returns
486
486
  * whether ANY query's remoteArray actually changed — the scheduler uses this
487
487
  * to drive the adaptive idle backoff.
488
+ *
489
+ * Also the ONLY health signal that runs while the page is idle. Sync health is
490
+ * otherwise activity-driven (mutations/registrations via the scheduler,
491
+ * reconnect re-registration, self-heal), so on a quiet page a stale `degraded`
492
+ * would linger until the next mutation and a genuine idle drop would be
493
+ * invisible. We fold the cycle's aggregate reachability into `recordSyncOutcome`
494
+ * so idle health self-recovers (and self-degrades) with no user action. A clean
495
+ * cycle is idempotent when already healthy (`recordSyncOutcome` early-returns at
496
+ * `consecutiveSyncFailures === 0`), so a healthy idle page pays nothing.
488
497
  */
489
498
  private async pollListRefForActiveQueries(): Promise<boolean> {
490
499
  const hashes = this.dataModule.getActiveQueryHashes();
491
- if (hashes.length === 0) return false;
500
+ if (hashes.length === 0) {
501
+ // No active queries to piggyback on, but health still needs a heartbeat —
502
+ // probe connectivity directly so an idle page with no live queries doesn't
503
+ // go blind. Cheap, and gated by the same adaptive backoff (≤5s idle cap).
504
+ try {
505
+ await this.remote.query('RETURN true');
506
+ this.recordSyncOutcome(true);
507
+ } catch (err) {
508
+ this.recordSyncOutcome(false, err);
509
+ }
510
+ return false;
511
+ }
492
512
  let anyChanged = false;
513
+ // `reached` = the server answered at least once this cycle (a success, or an
514
+ // *application* error, which still proves reachability). `firstNetworkErr`
515
+ // holds the first network-classified failure. A cycle that only produced
516
+ // network errors reports the outcome as a down round; a mixed/app cycle counts
517
+ // as reached; an all-application cycle reports nothing (that's a query-shape
518
+ // fault owned by the registration path, not a reachability signal).
519
+ let reached = false;
520
+ let firstNetworkErr: unknown;
493
521
  for (const hash of hashes) {
494
522
  try {
495
523
  if (await this.refetchListRefForQuery(hash)) anyChanged = true;
524
+ reached = true;
496
525
  } catch (err) {
526
+ if (classifySyncError(err) === 'network') {
527
+ if (firstNetworkErr === undefined) firstNetworkErr = err;
528
+ } else {
529
+ reached = true;
530
+ }
497
531
  this.logger.debug(
498
532
  { err: (err as Error)?.message ?? err, hash, Category: 'sp00ky-client::Sp00kySync::pollListRefForActiveQueries' },
499
533
  'Per-query list_ref poll failed'
500
534
  );
501
535
  }
502
536
  }
537
+ // Call the private outcome recorder directly rather than routing through the
538
+ // scheduler — the scheduler only reports on rounds that drained ≥1 queue item
539
+ // (`processedAny`), and this isn't a queue round.
540
+ if (reached) {
541
+ this.recordSyncOutcome(true);
542
+ } else if (firstNetworkErr !== undefined) {
543
+ this.recordSyncOutcome(false, firstNetworkErr);
544
+ }
503
545
  return anyChanged;
504
546
  }
505
547
 
@@ -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',