@spooky-sync/core 0.0.1-canary.85 → 0.0.1-canary.87
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 +11 -0
- package/dist/index.js +69 -3
- package/package.json +3 -3
- package/src/modules/devtools/index.ts +26 -0
- package/src/modules/sync/sync.ts +78 -0
package/dist/index.d.ts
CHANGED
|
@@ -638,6 +638,10 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
638
638
|
private syncHealthStatus;
|
|
639
639
|
private lastSyncErrorKind;
|
|
640
640
|
private lastSyncErrorMessage;
|
|
641
|
+
private selfHealTimer;
|
|
642
|
+
private selfHealAttempts;
|
|
643
|
+
private static readonly SELF_HEAL_BASE_MS;
|
|
644
|
+
private static readonly SELF_HEAL_MAX_MS;
|
|
641
645
|
/** Current sync-health snapshot. */
|
|
642
646
|
get syncHealth(): SyncHealth;
|
|
643
647
|
/**
|
|
@@ -655,6 +659,13 @@ declare class Sp00kySync<S extends SchemaStructure> {
|
|
|
655
659
|
* is 0).
|
|
656
660
|
*/
|
|
657
661
|
private recordSyncOutcome;
|
|
662
|
+
/**
|
|
663
|
+
* Begin self-heal retries (no-op if already running). Started on the
|
|
664
|
+
* healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
|
|
665
|
+
*/
|
|
666
|
+
private startSelfHeal;
|
|
667
|
+
private scheduleSelfHeal;
|
|
668
|
+
private stopSelfHeal;
|
|
658
669
|
constructor(local: LocalDatabaseService, remote: RemoteDatabaseService, cache: CacheModule, dataModule: DataModule<S>, schema: S, logger: Logger$1, options?: Sp00kySyncOptions);
|
|
659
670
|
/**
|
|
660
671
|
* Initializes the synchronization system.
|
package/dist/index.js
CHANGED
|
@@ -2626,7 +2626,7 @@ function listRefTableFor(mode, userId) {
|
|
|
2626
2626
|
* Uses a queue-based architecture with 'up' (local to remote) and 'down' (remote to local) queues.
|
|
2627
2627
|
* @template S The schema structure type.
|
|
2628
2628
|
*/
|
|
2629
|
-
var Sp00kySync = class {
|
|
2629
|
+
var Sp00kySync = class Sp00kySync {
|
|
2630
2630
|
upQueue;
|
|
2631
2631
|
downQueue;
|
|
2632
2632
|
isInit = false;
|
|
@@ -2675,6 +2675,10 @@ var Sp00kySync = class {
|
|
|
2675
2675
|
syncHealthStatus = "healthy";
|
|
2676
2676
|
lastSyncErrorKind;
|
|
2677
2677
|
lastSyncErrorMessage;
|
|
2678
|
+
selfHealTimer = null;
|
|
2679
|
+
selfHealAttempts = 0;
|
|
2680
|
+
static SELF_HEAL_BASE_MS = 2e3;
|
|
2681
|
+
static SELF_HEAL_MAX_MS = 3e4;
|
|
2678
2682
|
/** Current sync-health snapshot. */
|
|
2679
2683
|
get syncHealth() {
|
|
2680
2684
|
return {
|
|
@@ -2713,6 +2717,7 @@ var Sp00kySync = class {
|
|
|
2713
2717
|
this.syncHealthStatus = "healthy";
|
|
2714
2718
|
this.lastSyncErrorKind = void 0;
|
|
2715
2719
|
this.lastSyncErrorMessage = void 0;
|
|
2720
|
+
this.stopSelfHeal();
|
|
2716
2721
|
this.logger.info({ Category: "sp00ky-client::Sp00kySync::syncHealth" }, "Sync recovered; health back to healthy");
|
|
2717
2722
|
this.emitSyncHealth();
|
|
2718
2723
|
}
|
|
@@ -2730,8 +2735,58 @@ var Sp00kySync = class {
|
|
|
2730
2735
|
Category: "sp00ky-client::Sp00kySync::syncHealth"
|
|
2731
2736
|
}, "Sync degraded after sustained failures");
|
|
2732
2737
|
this.emitSyncHealth();
|
|
2738
|
+
this.startSelfHeal();
|
|
2733
2739
|
}
|
|
2734
2740
|
}
|
|
2741
|
+
/**
|
|
2742
|
+
* Begin self-heal retries (no-op if already running). Started on the
|
|
2743
|
+
* healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
|
|
2744
|
+
*/
|
|
2745
|
+
startSelfHeal() {
|
|
2746
|
+
if (this.selfHealTimer !== null) return;
|
|
2747
|
+
this.selfHealAttempts = 0;
|
|
2748
|
+
this.scheduleSelfHeal();
|
|
2749
|
+
}
|
|
2750
|
+
scheduleSelfHeal() {
|
|
2751
|
+
const delay = Math.min(Sp00kySync.SELF_HEAL_MAX_MS, Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts);
|
|
2752
|
+
this.selfHealTimer = setTimeout(async () => {
|
|
2753
|
+
this.selfHealTimer = null;
|
|
2754
|
+
if (this.syncHealthStatus !== "degraded") return;
|
|
2755
|
+
this.selfHealAttempts++;
|
|
2756
|
+
this.logger.debug({
|
|
2757
|
+
attempt: this.selfHealAttempts,
|
|
2758
|
+
delayMs: delay,
|
|
2759
|
+
Category: "sp00ky-client::Sp00kySync::selfHeal"
|
|
2760
|
+
}, "Self-heal: re-driving sync while degraded");
|
|
2761
|
+
try {
|
|
2762
|
+
if (this.upQueue.size > 0) await this.scheduler.syncUp();
|
|
2763
|
+
else if (this.downQueue.size > 0) await this.scheduler.syncDown();
|
|
2764
|
+
else {
|
|
2765
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
2766
|
+
if (hashes.length > 0) {
|
|
2767
|
+
for (const hash of hashes) this.scheduler.enqueueDownEvent({
|
|
2768
|
+
type: "register",
|
|
2769
|
+
payload: { hash }
|
|
2770
|
+
});
|
|
2771
|
+
await this.scheduler.syncDown();
|
|
2772
|
+
} else {
|
|
2773
|
+
await this.remote.query("RETURN true");
|
|
2774
|
+
this.recordSyncOutcome(true);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
} catch (err) {
|
|
2778
|
+
this.recordSyncOutcome(false, err);
|
|
2779
|
+
}
|
|
2780
|
+
if (this.syncHealthStatus === "degraded") this.scheduleSelfHeal();
|
|
2781
|
+
}, delay);
|
|
2782
|
+
}
|
|
2783
|
+
stopSelfHeal() {
|
|
2784
|
+
if (this.selfHealTimer !== null) {
|
|
2785
|
+
clearTimeout(this.selfHealTimer);
|
|
2786
|
+
this.selfHealTimer = null;
|
|
2787
|
+
}
|
|
2788
|
+
this.selfHealAttempts = 0;
|
|
2789
|
+
}
|
|
2735
2790
|
constructor(local, remote, cache, dataModule, schema, logger, options) {
|
|
2736
2791
|
this.local = local;
|
|
2737
2792
|
this.remote = remote;
|
|
@@ -3429,14 +3484,15 @@ function parseBackendInfo(raw) {
|
|
|
3429
3484
|
|
|
3430
3485
|
//#endregion
|
|
3431
3486
|
//#region src/modules/devtools/index.ts
|
|
3432
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
3433
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
3487
|
+
const CORE_VERSION = "0.0.1-canary.87";
|
|
3488
|
+
const WASM_VERSION = "0.0.1-canary.87";
|
|
3434
3489
|
const SURREAL_VERSION = "3.0.3";
|
|
3435
3490
|
var DevToolsService = class {
|
|
3436
3491
|
eventsHistory = [];
|
|
3437
3492
|
eventIdCounter = 0;
|
|
3438
3493
|
version = CORE_VERSION;
|
|
3439
3494
|
backendInfo = emptyBackendInfo();
|
|
3495
|
+
enabled = false;
|
|
3440
3496
|
constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
|
|
3441
3497
|
this.databaseService = databaseService;
|
|
3442
3498
|
this.remoteDatabaseService = remoteDatabaseService;
|
|
@@ -3445,6 +3501,14 @@ var DevToolsService = class {
|
|
|
3445
3501
|
this.authService = authService;
|
|
3446
3502
|
this.dataManager = dataManager;
|
|
3447
3503
|
this.exposeToWindow();
|
|
3504
|
+
if (typeof window !== "undefined") window.addEventListener("message", (e) => {
|
|
3505
|
+
if (e.source !== window) return;
|
|
3506
|
+
const type = e.data?.type;
|
|
3507
|
+
if (type === "SP00KY_DEVTOOLS_CONNECT") {
|
|
3508
|
+
this.enabled = true;
|
|
3509
|
+
this.notifyDevTools();
|
|
3510
|
+
} else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
|
|
3511
|
+
});
|
|
3448
3512
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
3449
3513
|
this.notifyDevTools();
|
|
3450
3514
|
});
|
|
@@ -3551,6 +3615,7 @@ var DevToolsService = class {
|
|
|
3551
3615
|
this.notifyDevTools();
|
|
3552
3616
|
}
|
|
3553
3617
|
addEvent(eventType, payload) {
|
|
3618
|
+
if (!this.enabled) return;
|
|
3554
3619
|
this.eventsHistory.push({
|
|
3555
3620
|
id: this.eventIdCounter++,
|
|
3556
3621
|
timestamp: Date.now(),
|
|
@@ -3584,6 +3649,7 @@ var DevToolsService = class {
|
|
|
3584
3649
|
});
|
|
3585
3650
|
}
|
|
3586
3651
|
notifyDevTools() {
|
|
3652
|
+
if (!this.enabled) return;
|
|
3587
3653
|
if (typeof window !== "undefined") window.postMessage({
|
|
3588
3654
|
type: "SP00KY_STATE_CHANGED",
|
|
3589
3655
|
source: "sp00ky-devtools-page",
|
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.87",
|
|
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.87",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.87",
|
|
64
64
|
"@surrealdb/wasm": "^3.0.3",
|
|
65
65
|
"fast-json-patch": "^3.1.1",
|
|
66
66
|
"loro-crdt": "^1.5.6",
|
|
@@ -42,6 +42,12 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
42
42
|
// Backend stack info (versions + per-entity status), read via the
|
|
43
43
|
// `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
|
|
44
44
|
private backendInfo: BackendInfo = emptyBackendInfo();
|
|
45
|
+
// Dormant until a devtools consumer (extension panel or MCP) handshakes via
|
|
46
|
+
// `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()`/`addEvent()` do no
|
|
47
|
+
// work, so prod pays zero serialization/postMessage cost for an unwatched panel.
|
|
48
|
+
// `window.__00__.getState()` stays live regardless, so the panel's first paint
|
|
49
|
+
// (the on-demand GET_STATE pull) still works before the push channel turns on.
|
|
50
|
+
private enabled = false;
|
|
45
51
|
|
|
46
52
|
constructor(
|
|
47
53
|
private databaseService: LocalDatabaseService,
|
|
@@ -53,6 +59,22 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
53
59
|
) {
|
|
54
60
|
this.exposeToWindow();
|
|
55
61
|
|
|
62
|
+
// Stay dormant until a devtools consumer announces itself. The extension's
|
|
63
|
+
// page-script posts this once it detects `window.__00__`; the panel can also
|
|
64
|
+
// disconnect to return us to dormant. Until then we skip all serialization.
|
|
65
|
+
if (typeof window !== 'undefined') {
|
|
66
|
+
window.addEventListener('message', (e) => {
|
|
67
|
+
if (e.source !== window) return;
|
|
68
|
+
const type = (e.data as { type?: string } | undefined)?.type;
|
|
69
|
+
if (type === 'SP00KY_DEVTOOLS_CONNECT') {
|
|
70
|
+
this.enabled = true;
|
|
71
|
+
this.notifyDevTools();
|
|
72
|
+
} else if (type === 'SP00KY_DEVTOOLS_DISCONNECT') {
|
|
73
|
+
this.enabled = false;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
// Subscribe to auth events
|
|
57
79
|
this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
|
|
58
80
|
this.notifyDevTools();
|
|
@@ -197,6 +219,8 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
197
219
|
}
|
|
198
220
|
|
|
199
221
|
private addEvent(eventType: string, payload: any) {
|
|
222
|
+
// No consumer attached → skip recording (and the recursive serialize it does).
|
|
223
|
+
if (!this.enabled) return;
|
|
200
224
|
this.eventsHistory.push({
|
|
201
225
|
id: this.eventIdCounter++,
|
|
202
226
|
timestamp: Date.now(),
|
|
@@ -232,6 +256,8 @@ export class DevToolsService implements StreamUpdateReceiver {
|
|
|
232
256
|
}
|
|
233
257
|
|
|
234
258
|
private notifyDevTools() {
|
|
259
|
+
// No consumer attached → no getState() serialization, no postMessage broadcast.
|
|
260
|
+
if (!this.enabled) return;
|
|
235
261
|
if (typeof window !== 'undefined') {
|
|
236
262
|
window.postMessage(
|
|
237
263
|
{
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -169,6 +169,16 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
169
169
|
private lastSyncErrorKind: 'network' | 'application' | undefined;
|
|
170
170
|
private lastSyncErrorMessage: string | undefined;
|
|
171
171
|
|
|
172
|
+
// Self-heal: while degraded, re-drive sync on an exponential backoff so the
|
|
173
|
+
// app recovers on its own — even when the socket never actually dropped (in
|
|
174
|
+
// that case no `connected` event fires, so this re-registration is the ONLY
|
|
175
|
+
// thing that re-probes the server). Started on the degrade transition,
|
|
176
|
+
// cleared on recovery. Capped cadence so a long outage doesn't busy-loop.
|
|
177
|
+
private selfHealTimer: ReturnType<typeof setTimeout> | null = null;
|
|
178
|
+
private selfHealAttempts = 0;
|
|
179
|
+
private static readonly SELF_HEAL_BASE_MS = 2_000;
|
|
180
|
+
private static readonly SELF_HEAL_MAX_MS = 30_000;
|
|
181
|
+
|
|
172
182
|
/** Current sync-health snapshot. */
|
|
173
183
|
get syncHealth(): SyncHealth {
|
|
174
184
|
return {
|
|
@@ -212,6 +222,7 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
212
222
|
this.syncHealthStatus = 'healthy';
|
|
213
223
|
this.lastSyncErrorKind = undefined;
|
|
214
224
|
this.lastSyncErrorMessage = undefined;
|
|
225
|
+
this.stopSelfHeal();
|
|
215
226
|
this.logger.info(
|
|
216
227
|
{ Category: 'sp00ky-client::Sp00kySync::syncHealth' },
|
|
217
228
|
'Sync recovered; health back to healthy'
|
|
@@ -238,7 +249,74 @@ export class Sp00kySync<S extends SchemaStructure> {
|
|
|
238
249
|
'Sync degraded after sustained failures'
|
|
239
250
|
);
|
|
240
251
|
this.emitSyncHealth();
|
|
252
|
+
this.startSelfHeal();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Begin self-heal retries (no-op if already running). Started on the
|
|
258
|
+
* healthy→degraded transition; {@link recordSyncOutcome} stops it on recovery.
|
|
259
|
+
*/
|
|
260
|
+
private startSelfHeal(): void {
|
|
261
|
+
if (this.selfHealTimer !== null) return;
|
|
262
|
+
this.selfHealAttempts = 0;
|
|
263
|
+
this.scheduleSelfHeal();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private scheduleSelfHeal(): void {
|
|
267
|
+
const delay = Math.min(
|
|
268
|
+
Sp00kySync.SELF_HEAL_MAX_MS,
|
|
269
|
+
Sp00kySync.SELF_HEAL_BASE_MS * 2 ** this.selfHealAttempts
|
|
270
|
+
);
|
|
271
|
+
this.selfHealTimer = setTimeout(async () => {
|
|
272
|
+
this.selfHealTimer = null;
|
|
273
|
+
if (this.syncHealthStatus !== 'degraded') return;
|
|
274
|
+
this.selfHealAttempts++;
|
|
275
|
+
this.logger.debug(
|
|
276
|
+
{ attempt: this.selfHealAttempts, delayMs: delay, Category: 'sp00ky-client::Sp00kySync::selfHeal' },
|
|
277
|
+
'Self-heal: re-driving sync while degraded'
|
|
278
|
+
);
|
|
279
|
+
try {
|
|
280
|
+
// Retry whatever is still queued first; the failing op (register or
|
|
281
|
+
// mutation) was re-queued by the queue, so this re-probes the server
|
|
282
|
+
// and reports the outcome through the scheduler → recordSyncOutcome.
|
|
283
|
+
if (this.upQueue.size > 0) {
|
|
284
|
+
await this.scheduler.syncUp();
|
|
285
|
+
} else if (this.downQueue.size > 0) {
|
|
286
|
+
await this.scheduler.syncDown();
|
|
287
|
+
} else {
|
|
288
|
+
// Nothing queued (e.g. the failing op was rolled back + dropped):
|
|
289
|
+
// re-register active queries — mirroring the reconnect handler — so
|
|
290
|
+
// there's a concrete op whose success flips health. If there are no
|
|
291
|
+
// active queries either, probe connectivity directly.
|
|
292
|
+
const hashes = this.dataModule.getActiveQueryHashes();
|
|
293
|
+
if (hashes.length > 0) {
|
|
294
|
+
for (const hash of hashes) {
|
|
295
|
+
this.scheduler.enqueueDownEvent({ type: 'register', payload: { hash } });
|
|
296
|
+
}
|
|
297
|
+
await this.scheduler.syncDown();
|
|
298
|
+
} else {
|
|
299
|
+
await this.remote.query('RETURN true');
|
|
300
|
+
this.recordSyncOutcome(true);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// Only the direct connectivity probe can throw here (syncUp/syncDown
|
|
305
|
+
// swallow + self-report); treat a probe failure as another failed round.
|
|
306
|
+
this.recordSyncOutcome(false, err);
|
|
307
|
+
}
|
|
308
|
+
// Keep retrying until recovery. recordSyncOutcome(true) calls stopSelfHeal
|
|
309
|
+
// (clearing any pending timer), so only continue while still degraded.
|
|
310
|
+
if (this.syncHealthStatus === 'degraded') this.scheduleSelfHeal();
|
|
311
|
+
}, delay);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private stopSelfHeal(): void {
|
|
315
|
+
if (this.selfHealTimer !== null) {
|
|
316
|
+
clearTimeout(this.selfHealTimer);
|
|
317
|
+
this.selfHealTimer = null;
|
|
241
318
|
}
|
|
319
|
+
this.selfHealAttempts = 0;
|
|
242
320
|
}
|
|
243
321
|
|
|
244
322
|
constructor(
|