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

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,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.101";
3516
- const WASM_VERSION = "0.0.1-canary.101";
3518
+ const CORE_VERSION = "0.0.1-canary.102";
3519
+ const WASM_VERSION = "0.0.1-canary.102";
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.101",
3
+ "version": "0.0.1-canary.102",
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.101",
63
- "@spooky-sync/ssp-wasm": "0.0.1-canary.101",
62
+ "@spooky-sync/query-builder": "0.0.1-canary.102",
63
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.102",
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
- tables: this.schema.tables.map((t) => t.name),
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
  });
@@ -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;