@spooky-sync/core 0.0.1-canary.85 → 0.0.1-canary.86

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,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,8 +3484,8 @@ function parseBackendInfo(raw) {
3429
3484
 
3430
3485
  //#endregion
3431
3486
  //#region src/modules/devtools/index.ts
3432
- const CORE_VERSION = "0.0.1-canary.85";
3433
- const WASM_VERSION = "0.0.1-canary.85";
3487
+ const CORE_VERSION = "0.0.1-canary.86";
3488
+ const WASM_VERSION = "0.0.1-canary.86";
3434
3489
  const SURREAL_VERSION = "3.0.3";
3435
3490
  var DevToolsService = class {
3436
3491
  eventsHistory = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.85",
3
+ "version": "0.0.1-canary.86",
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.85",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.85",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.86",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.86",
64
64
  "@surrealdb/wasm": "^3.0.3",
65
65
  "fast-json-patch": "^3.1.1",
66
66
  "loro-crdt": "^1.5.6",
@@ -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(