@spooky-sync/core 0.0.1-canary.101 → 0.0.1-canary.103
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 +1 -0
- package/dist/index.js +42 -4
- package/dist/types.d.ts +7 -0
- package/package.json +3 -3
- package/src/modules/devtools/index.ts +56 -1
- package/src/modules/sync/sync.health.test.ts +33 -0
- package/src/modules/sync/sync.ts +8 -0
- package/src/types.ts +7 -0
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;
|
package/dist/index.js
CHANGED
|
@@ -2678,6 +2678,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
2678
2678
|
syncHealthStatus = "healthy";
|
|
2679
2679
|
lastSyncErrorKind;
|
|
2680
2680
|
lastSyncErrorMessage;
|
|
2681
|
+
hasSyncedOnce = false;
|
|
2681
2682
|
selfHealTimer = null;
|
|
2682
2683
|
selfHealAttempts = 0;
|
|
2683
2684
|
static SELF_HEAL_BASE_MS = 2e3;
|
|
@@ -2688,7 +2689,8 @@ var Sp00kySync = class Sp00kySync {
|
|
|
2688
2689
|
status: this.syncHealthStatus,
|
|
2689
2690
|
consecutiveFailures: this.consecutiveSyncFailures,
|
|
2690
2691
|
kind: this.syncHealthStatus === "degraded" ? this.lastSyncErrorKind : void 0,
|
|
2691
|
-
error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0
|
|
2692
|
+
error: this.syncHealthStatus === "degraded" ? this.lastSyncErrorMessage : void 0,
|
|
2693
|
+
everConnected: this.hasSyncedOnce
|
|
2692
2694
|
};
|
|
2693
2695
|
}
|
|
2694
2696
|
/**
|
|
@@ -2714,6 +2716,7 @@ var Sp00kySync = class Sp00kySync {
|
|
|
2714
2716
|
recordSyncOutcome(ok, error) {
|
|
2715
2717
|
if (this.degradeAfterFailures <= 0) return;
|
|
2716
2718
|
if (ok) {
|
|
2719
|
+
this.hasSyncedOnce = true;
|
|
2717
2720
|
if (this.consecutiveSyncFailures === 0) return;
|
|
2718
2721
|
this.consecutiveSyncFailures = 0;
|
|
2719
2722
|
if (this.syncHealthStatus !== "healthy") {
|
|
@@ -3512,8 +3515,8 @@ function parseBackendInfo(raw) {
|
|
|
3512
3515
|
|
|
3513
3516
|
//#endregion
|
|
3514
3517
|
//#region src/modules/devtools/index.ts
|
|
3515
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
3516
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
3518
|
+
const CORE_VERSION = "0.0.1-canary.103";
|
|
3519
|
+
const WASM_VERSION = "0.0.1-canary.103";
|
|
3517
3520
|
const SURREAL_VERSION = "3.0.3";
|
|
3518
3521
|
var DevToolsService = class {
|
|
3519
3522
|
eventsHistory = [];
|
|
@@ -3521,6 +3524,9 @@ var DevToolsService = class {
|
|
|
3521
3524
|
version = CORE_VERSION;
|
|
3522
3525
|
backendInfo = emptyBackendInfo();
|
|
3523
3526
|
enabled = false;
|
|
3527
|
+
localTables = [];
|
|
3528
|
+
localTablesFetching = false;
|
|
3529
|
+
localTablesAt = 0;
|
|
3524
3530
|
constructor(databaseService, remoteDatabaseService, logger, schema, authService, dataManager) {
|
|
3525
3531
|
this.databaseService = databaseService;
|
|
3526
3532
|
this.remoteDatabaseService = remoteDatabaseService;
|
|
@@ -3574,6 +3580,7 @@ var DevToolsService = class {
|
|
|
3574
3580
|
createdAt: q.config.lastActiveAt instanceof Date ? q.config.lastActiveAt.getTime() : new Date(q.config.lastActiveAt || Date.now()).getTime(),
|
|
3575
3581
|
lastUpdate: Date.now(),
|
|
3576
3582
|
updateCount: q.updateCount,
|
|
3583
|
+
ttl: q.config.ttl,
|
|
3577
3584
|
query: q.config.surql,
|
|
3578
3585
|
variables: q.config.params || {},
|
|
3579
3586
|
dataSize: q.records?.length || 0,
|
|
@@ -3652,7 +3659,38 @@ var DevToolsService = class {
|
|
|
3652
3659
|
});
|
|
3653
3660
|
if (this.eventsHistory.length > 100) this.eventsHistory.shift();
|
|
3654
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
|
+
}
|
|
3655
3692
|
getState() {
|
|
3693
|
+
this.refreshLocalTables();
|
|
3656
3694
|
return this.serializeForDevTools({
|
|
3657
3695
|
eventsHistory: [...this.eventsHistory],
|
|
3658
3696
|
activeQueries: Object.fromEntries(this.getActiveQueries()),
|
|
@@ -3671,7 +3709,7 @@ var DevToolsService = class {
|
|
|
3671
3709
|
entities: this.backendInfo.entities
|
|
3672
3710
|
},
|
|
3673
3711
|
database: {
|
|
3674
|
-
tables: this.schema.tables.map((t) => t.name),
|
|
3712
|
+
tables: this.localTables.length ? this.localTables : this.schema.tables.map((t) => t.name),
|
|
3675
3713
|
tableData: {}
|
|
3676
3714
|
}
|
|
3677
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.
|
|
3
|
+
"version": "0.0.1-canary.103",
|
|
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.103",
|
|
63
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.103",
|
|
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
|
-
|
|
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
|
});
|
|
@@ -113,4 +113,37 @@ describe('sync health via idle poll', () => {
|
|
|
113
113
|
await poll();
|
|
114
114
|
expect(sync.syncHealth.status).toBe('healthy');
|
|
115
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
|
+
});
|
|
116
149
|
});
|
package/src/modules/sync/sync.ts
CHANGED
|
@@ -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') {
|
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;
|