@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)
|
|
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.
|
|
3489
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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.
|
|
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.
|
|
63
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
+
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -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)
|
|
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',
|