@spooky-sync/core 0.0.1-canary.153 → 0.0.1-canary.154

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.js CHANGED
@@ -1141,6 +1141,7 @@ function renderRelationFetchSurql(req) {
1141
1141
  var SurrealCacheEngine = class extends LocalDatabaseService {
1142
1142
  /** SurrealDB needs its SurrealQL schema provisioned locally. */
1143
1143
  usesSurqlSchema = true;
1144
+ engineKind = "surrealdb";
1144
1145
  /** {@link LocalCacheEngine} alias for {@link LocalDatabaseService.switchStore}. */
1145
1146
  switchBucket(bucketId) {
1146
1147
  return this.switchStore(bucketId);
@@ -1509,6 +1510,9 @@ var SqliteCacheEngine = class {
1509
1510
  * Flipped off at runtime if the worker script predates the `select` op
1510
1511
  * (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
1511
1512
  workerSelect;
1513
+ /** What `workerSelect` was at construction, so DevTools can tell a runtime
1514
+ * downgrade (configured true, effective false) from a configured-off. */
1515
+ workerSelectConfigured;
1512
1516
  events = createDatabaseEventSystem();
1513
1517
  bucketId = "anon";
1514
1518
  /** Durability of the local store, set on every open. A plain Set of callbacks
@@ -1521,11 +1525,13 @@ var SqliteCacheEngine = class {
1521
1525
  storageHealthSubs = /* @__PURE__ */ new Set();
1522
1526
  /** Schemaless — tables are created lazily on first write; no migrator. */
1523
1527
  usesSurqlSchema = false;
1528
+ engineKind = "sqlite";
1524
1529
  constructor(config, logger, opts = {}) {
1525
1530
  this.config = config;
1526
1531
  this.logger = logger;
1527
1532
  this.useOpfs = opts.useOpfs ?? true;
1528
1533
  this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
1534
+ this.workerSelectConfigured = this.workerSelect;
1529
1535
  }
1530
1536
  get epoch() {
1531
1537
  return this.storeEpoch;
@@ -1553,6 +1559,40 @@ var SqliteCacheEngine = class {
1553
1559
  getConfig() {
1554
1560
  return this.config;
1555
1561
  }
1562
+ /**
1563
+ * Storage numbers for the DevTools Storage tab. Uses {@link call} so the
1564
+ * reads serialize with regular traffic (no SQLITE_BUSY). Never throws — the
1565
+ * worker may be mid bucket-switch; a failure lands in `error` instead.
1566
+ */
1567
+ async getStorageDiagnostics(opts) {
1568
+ const diag = {
1569
+ engine: "sqlite",
1570
+ bucketId: this.bucketId,
1571
+ useOpfs: this.useOpfs,
1572
+ workerSelectConfigured: this.workerSelectConfigured,
1573
+ workerSelectEffective: this.workerSelect
1574
+ };
1575
+ try {
1576
+ const { rows } = await this.call("exec", { sql: "SELECT (SELECT * FROM pragma_page_count()) * (SELECT * FROM pragma_page_size()) AS bytes, (SELECT * FROM pragma_freelist_count()) * (SELECT * FROM pragma_page_size()) AS freelist" });
1577
+ diag.dbSizeBytes = rows?.[0]?.bytes;
1578
+ diag.freelistBytes = rows?.[0]?.freelist;
1579
+ if (opts?.tableCounts) {
1580
+ const { rows: tables } = await this.call("exec", { sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" });
1581
+ const names = (tables ?? []).map((r) => r.name);
1582
+ if (names.length) {
1583
+ const sql = names.map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, "\"\"")}"`).join(" UNION ALL ");
1584
+ const { rows: counts } = await this.call("exec", { sql });
1585
+ diag.tableCounts = (counts ?? []).map((r) => ({
1586
+ table: r.t,
1587
+ rows: r.n
1588
+ }));
1589
+ } else diag.tableCounts = [];
1590
+ }
1591
+ } catch (e) {
1592
+ diag.error = e instanceof Error ? e.message : String(e);
1593
+ }
1594
+ return diag;
1595
+ }
1556
1596
  getEvents() {
1557
1597
  return this.events;
1558
1598
  }
@@ -1659,14 +1699,16 @@ var SqliteCacheEngine = class {
1659
1699
  for (const t of SYSTEM_TABLES) this.knownTables.add(t);
1660
1700
  this.bucketId = bucketId;
1661
1701
  const fellBack = this.useOpfs && !persisted;
1662
- this.setStorageHealth({
1702
+ const health = {
1663
1703
  status: persisted ? "persistent" : "memory",
1664
- fallback: fellBack,
1665
- error: fellBack ? opfsError : void 0
1666
- });
1704
+ fallback: fellBack
1705
+ };
1706
+ if (fellBack && opfsError) health.error = opfsError;
1707
+ this.setStorageHealth(health);
1667
1708
  const stats = getStats();
1668
1709
  stats.persisted = persisted;
1669
- stats.opfsError = fellBack ? opfsError : void 0;
1710
+ if (fellBack && opfsError) stats.opfsError = opfsError;
1711
+ else delete stats.opfsError;
1670
1712
  if (fellBack) this.logger.error({
1671
1713
  bucketId,
1672
1714
  opfsError,
@@ -5088,10 +5130,79 @@ function parseBackendInfo(raw) {
5088
5130
  };
5089
5131
  }
5090
5132
 
5133
+ //#endregion
5134
+ //#region src/modules/devtools/storage-info.ts
5135
+ /**
5136
+ * Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
5137
+ * which throws for a file another context holds an exclusive sync access
5138
+ * handle on — SAHPool does exactly that for its whole pool, so a locked file
5139
+ * (size omitted) is a live "who has the pool" signal, not a failure.
5140
+ */
5141
+ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
5142
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
5143
+ if (!nav?.storage?.getDirectory) return {
5144
+ supported: false,
5145
+ entries: [],
5146
+ totalBytes: 0,
5147
+ truncated: false
5148
+ };
5149
+ const entries = [];
5150
+ let totalBytes = 0;
5151
+ let truncated = false;
5152
+ try {
5153
+ const root = await nav.storage.getDirectory();
5154
+ const walk = async (dir, prefix, depth) => {
5155
+ if (depth > maxDepth) return;
5156
+ for await (const [name, handle] of dir.entries()) {
5157
+ if (entries.length >= maxEntries) {
5158
+ truncated = true;
5159
+ return;
5160
+ }
5161
+ const path = prefix ? `${prefix}/${name}` : name;
5162
+ if (handle.kind === "directory") {
5163
+ entries.push({
5164
+ path,
5165
+ kind: "directory"
5166
+ });
5167
+ await walk(handle, path, depth + 1);
5168
+ } else {
5169
+ let size;
5170
+ try {
5171
+ size = (await handle.getFile()).size;
5172
+ totalBytes += size;
5173
+ } catch {}
5174
+ const entry = {
5175
+ path,
5176
+ kind: "file"
5177
+ };
5178
+ if (size !== void 0) entry.size = size;
5179
+ entries.push(entry);
5180
+ }
5181
+ }
5182
+ };
5183
+ await walk(root, "", 0);
5184
+ entries.sort((a, b) => a.path.localeCompare(b.path));
5185
+ return {
5186
+ supported: true,
5187
+ entries,
5188
+ totalBytes,
5189
+ truncated
5190
+ };
5191
+ } catch (e) {
5192
+ return {
5193
+ supported: true,
5194
+ entries,
5195
+ totalBytes,
5196
+ truncated,
5197
+ error: e instanceof Error ? e.message : String(e)
5198
+ };
5199
+ }
5200
+ }
5201
+
5091
5202
  //#endregion
5092
5203
  //#region src/modules/devtools/index.ts
5093
- const CORE_VERSION = "0.0.1-canary.153";
5094
- const WASM_VERSION = "0.0.1-canary.153";
5204
+ const CORE_VERSION = "0.0.1-canary.154";
5205
+ const WASM_VERSION = "0.0.1-canary.154";
5095
5206
  const SURREAL_VERSION = "3.0.3";
5096
5207
  var DevToolsService = class {
5097
5208
  eventsHistory = [];
@@ -5296,6 +5407,69 @@ var DevToolsService = class {
5296
5407
  }
5297
5408
  });
5298
5409
  }
5410
+ /**
5411
+ * Full storage diagnostics for the DevTools Storage tab. Every section is
5412
+ * gathered independently and failures land in that section's `error` field,
5413
+ * so one broken source (a mid-switch worker, a browser without OPFS) never
5414
+ * blanks the whole panel.
5415
+ */
5416
+ async getStorageInfo(opts) {
5417
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
5418
+ const info = {
5419
+ at: Date.now(),
5420
+ engine: {
5421
+ kind: this.databaseService.engineKind ?? "custom",
5422
+ store: this.databaseService.getConfig()?.store ?? "memory",
5423
+ bucketId: this.databaseService.currentBucketId
5424
+ },
5425
+ health: this.databaseService.storageHealth ?? {
5426
+ status: "unknown",
5427
+ fallback: false
5428
+ },
5429
+ browser: {},
5430
+ opfs: {
5431
+ supported: false,
5432
+ entries: [],
5433
+ totalBytes: 0,
5434
+ truncated: false
5435
+ }
5436
+ };
5437
+ try {
5438
+ if (nav?.storage?.estimate) {
5439
+ const est = await nav.storage.estimate();
5440
+ info.browser.usage = est.usage;
5441
+ info.browser.quota = est.quota;
5442
+ const details = est.usageDetails;
5443
+ if (details && typeof details === "object") info.browser.usageDetails = details;
5444
+ }
5445
+ if (nav?.storage?.persisted) info.browser.persisted = await nav.storage.persisted();
5446
+ } catch (e) {
5447
+ info.browser.error = e instanceof Error ? e.message : String(e);
5448
+ }
5449
+ info.opfs = await walkOpfs();
5450
+ const stats = globalThis.__sqliteStats;
5451
+ if (stats && typeof stats === "object") info.sqliteStats = {
5452
+ ...stats,
5453
+ byType: { ...stats.byType ?? {} }
5454
+ };
5455
+ try {
5456
+ info.engineDiagnostics = await this.databaseService.getStorageDiagnostics?.(opts);
5457
+ } catch (e) {
5458
+ this.logger.warn({
5459
+ err: e,
5460
+ Category: "sp00ky-client::DevToolsService::getStorageInfo"
5461
+ }, "Engine storage diagnostics failed");
5462
+ }
5463
+ return this.serializeForDevTools(info);
5464
+ }
5465
+ /** Ask the browser to exempt this origin's storage from eviction. */
5466
+ async requestPersistentStorage() {
5467
+ try {
5468
+ return { granted: await navigator.storage?.persist?.() ?? false };
5469
+ } catch {
5470
+ return { granted: false };
5471
+ }
5472
+ }
5299
5473
  notifyDevTools() {
5300
5474
  if (!this.enabled) return;
5301
5475
  if (typeof window !== "undefined") window.postMessage({
@@ -5319,7 +5493,10 @@ var DevToolsService = class {
5319
5493
  if (seen.has(data)) return "[Circular Object]";
5320
5494
  seen.add(data);
5321
5495
  const result = {};
5322
- for (const key in data) if (Object.prototype.hasOwnProperty.call(data, key)) result[key] = this.serializeForDevTools(data[key], seen);
5496
+ for (const key in data) if (Object.prototype.hasOwnProperty.call(data, key)) {
5497
+ if (data[key] === void 0) continue;
5498
+ result[key] = this.serializeForDevTools(data[key], seen);
5499
+ }
5323
5500
  return result;
5324
5501
  }
5325
5502
  return data;
@@ -5334,6 +5511,8 @@ var DevToolsService = class {
5334
5511
  this.notifyDevTools();
5335
5512
  },
5336
5513
  refreshVersions: () => this.refreshBackendVersions(),
5514
+ getStorageInfo: (opts) => this.getStorageInfo(opts),
5515
+ requestPersistentStorage: () => this.requestPersistentStorage(),
5337
5516
  getTableData: async (tableName) => {
5338
5517
  try {
5339
5518
  const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
package/dist/types.d.ts CHANGED
@@ -179,6 +179,27 @@ interface SealedQuery<T = void> {
179
179
  readonly extract: (results: unknown[]) => T;
180
180
  }
181
181
  //#endregion
182
+ //#region src/modules/devtools/storage-info.d.ts
183
+ /** Engine-side numbers only the engine can produce (worker round-trips). */
184
+ interface EngineStorageDiagnostics {
185
+ engine: 'sqlite';
186
+ bucketId: string;
187
+ useOpfs: boolean;
188
+ workerSelectConfigured: boolean;
189
+ /** `false` while configured `true` means the runtime downgraded to the
190
+ * legacy multi-hop select (stale cached worker bundle). */
191
+ workerSelectEffective: boolean;
192
+ /** page_count * page_size. */
193
+ dbSizeBytes?: number;
194
+ /** freelist_count * page_size — reclaimable via VACUUM. */
195
+ freelistBytes?: number;
196
+ tableCounts?: {
197
+ table: string;
198
+ rows: number;
199
+ }[];
200
+ error?: string;
201
+ }
202
+ //#endregion
182
203
  //#region src/services/database/cache-engine.d.ts
183
204
  /**
184
205
  * A materialized row. Keys are field names; values are already decoded to the
@@ -288,6 +309,15 @@ interface LocalStore extends LocalCacheEngine {
288
309
  getClient(): unknown;
289
310
  getConfig(): Sp00kyConfig<any>['database'];
290
311
  readonly currentBucketId: string;
312
+ /** Which built-in backend this is. OPTIONAL: absent (custom engines) is
313
+ * reported as `'custom'` by DevTools. More robust than `instanceof` for
314
+ * engines constructed outside this package. */
315
+ readonly engineKind?: 'surrealdb' | 'sqlite';
316
+ /** Engine-specific storage numbers for DevTools (DB file size, per-table
317
+ * row counts). OPTIONAL: only engines with something to report implement it. */
318
+ getStorageDiagnostics?(opts?: {
319
+ tableCounts?: boolean;
320
+ }): Promise<EngineStorageDiagnostics>;
291
321
  /**
292
322
  * Durability of this engine's local store. OPTIONAL: engines that don't
293
323
  * report it (SurrealDB, custom engines) are treated as `'unknown'` by the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.153",
3
+ "version": "0.0.1-canary.154",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  }
61
61
  },
62
62
  "dependencies": {
63
- "@spooky-sync/query-builder": "0.0.1-canary.153",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.153",
63
+ "@spooky-sync/query-builder": "0.0.1-canary.154",
64
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.154",
65
65
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
66
66
  "@surrealdb/wasm": "^3.0.3",
67
67
  "fast-json-patch": "^3.1.1",
@@ -22,6 +22,7 @@ import {
22
22
  parseBackendInfo,
23
23
  UNAVAILABLE,
24
24
  } from './versions';
25
+ import { walkOpfs, type StorageInfo } from './storage-info';
25
26
 
26
27
  // Real bundled frontend versions, injected at build time by tsdown's
27
28
  // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
@@ -330,6 +331,72 @@ export class DevToolsService implements StreamUpdateReceiver {
330
331
  });
331
332
  }
332
333
 
334
+ /**
335
+ * Full storage diagnostics for the DevTools Storage tab. Every section is
336
+ * gathered independently and failures land in that section's `error` field,
337
+ * so one broken source (a mid-switch worker, a browser without OPFS) never
338
+ * blanks the whole panel.
339
+ */
340
+ public async getStorageInfo(opts?: { tableCounts?: boolean }): Promise<StorageInfo> {
341
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
342
+
343
+ const info: StorageInfo = {
344
+ at: Date.now(),
345
+ engine: {
346
+ kind: this.databaseService.engineKind ?? 'custom',
347
+ store: this.databaseService.getConfig()?.store ?? 'memory',
348
+ bucketId: this.databaseService.currentBucketId,
349
+ },
350
+ health: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
351
+ browser: {},
352
+ opfs: { supported: false, entries: [], totalBytes: 0, truncated: false },
353
+ };
354
+
355
+ try {
356
+ if (nav?.storage?.estimate) {
357
+ const est = await nav.storage.estimate();
358
+ info.browser.usage = est.usage;
359
+ info.browser.quota = est.quota;
360
+ // Chrome-only per-storage-system breakdown; absent elsewhere.
361
+ const details = (est as any).usageDetails;
362
+ if (details && typeof details === 'object') info.browser.usageDetails = details;
363
+ }
364
+ if (nav?.storage?.persisted) {
365
+ info.browser.persisted = await nav.storage.persisted();
366
+ }
367
+ } catch (e) {
368
+ info.browser.error = e instanceof Error ? e.message : String(e);
369
+ }
370
+
371
+ info.opfs = await walkOpfs();
372
+
373
+ const stats = (globalThis as any).__sqliteStats;
374
+ if (stats && typeof stats === 'object') {
375
+ info.sqliteStats = { ...stats, byType: { ...(stats.byType ?? {}) } };
376
+ }
377
+
378
+ try {
379
+ info.engineDiagnostics = await this.databaseService.getStorageDiagnostics?.(opts);
380
+ } catch (e) {
381
+ this.logger.warn(
382
+ { err: e, Category: 'sp00ky-client::DevToolsService::getStorageInfo' },
383
+ 'Engine storage diagnostics failed'
384
+ );
385
+ }
386
+
387
+ return this.serializeForDevTools(info);
388
+ }
389
+
390
+ /** Ask the browser to exempt this origin's storage from eviction. */
391
+ public async requestPersistentStorage(): Promise<{ granted: boolean }> {
392
+ try {
393
+ const granted = (await navigator.storage?.persist?.()) ?? false;
394
+ return { granted };
395
+ } catch {
396
+ return { granted: false };
397
+ }
398
+ }
399
+
333
400
  private notifyDevTools() {
334
401
  // No consumer attached → no getState() serialization, no postMessage broadcast.
335
402
  if (!this.enabled) return;
@@ -383,6 +450,10 @@ export class DevToolsService implements StreamUpdateReceiver {
383
450
  const result: Record<string, any> = {};
384
451
  for (const key in data) {
385
452
  if (Object.prototype.hasOwnProperty.call(data, key)) {
453
+ // Skip absent optional fields: recursing them would emit the STRING
454
+ // 'undefined' (the top-level mapping below), which panels then have
455
+ // to filter back out (see 3d84fe8a).
456
+ if (data[key] === undefined) continue;
386
457
  result[key] = this.serializeForDevTools(data[key], seen);
387
458
  }
388
459
  }
@@ -402,6 +473,8 @@ export class DevToolsService implements StreamUpdateReceiver {
402
473
  this.notifyDevTools();
403
474
  },
404
475
  refreshVersions: () => this.refreshBackendVersions(),
476
+ getStorageInfo: (opts?: { tableCounts?: boolean }) => this.getStorageInfo(opts),
477
+ requestPersistentStorage: () => this.requestPersistentStorage(),
405
478
  getTableData: async (tableName: string) => {
406
479
  try {
407
480
  // Returns the first statement result as T.
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
+ import { walkOpfs } from './storage-info';
3
+
4
+ /** Minimal in-memory OPFS: directories are nested objects, files are numbers
5
+ * (their size) or 'locked' (getFile() throws, like a live SAHPool handle). */
6
+ type FakeTree = { [name: string]: FakeTree | number | 'locked' };
7
+
8
+ function makeDirHandle(tree: FakeTree): any {
9
+ return {
10
+ kind: 'directory',
11
+ entries: async function* () {
12
+ for (const [name, node] of Object.entries(tree)) {
13
+ if (typeof node === 'object') {
14
+ yield [name, makeDirHandle(node)];
15
+ } else {
16
+ yield [
17
+ name,
18
+ {
19
+ kind: 'file',
20
+ getFile: async () => {
21
+ if (node === 'locked') throw new DOMException('locked', 'NoModificationAllowedError');
22
+ return { size: node };
23
+ },
24
+ },
25
+ ];
26
+ }
27
+ }
28
+ },
29
+ };
30
+ }
31
+
32
+ function stubOpfs(tree: FakeTree | null) {
33
+ vi.stubGlobal('navigator', tree === null ? {} : {
34
+ storage: { getDirectory: async () => makeDirHandle(tree) },
35
+ });
36
+ }
37
+
38
+ afterEach(() => {
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ describe('walkOpfs', () => {
43
+ it('reports unsupported without OPFS APIs', async () => {
44
+ stubOpfs(null);
45
+ expect(await walkOpfs()).toEqual({
46
+ supported: false,
47
+ entries: [],
48
+ totalBytes: 0,
49
+ truncated: false,
50
+ });
51
+ });
52
+
53
+ it('walks recursively, sums readable sizes, and omits size for locked files', async () => {
54
+ stubOpfs({
55
+ '.sp00ky-anon': { '0000000001': 4096, '0000000002': 'locked' },
56
+ 'other.txt': 10,
57
+ });
58
+ const res = await walkOpfs();
59
+ expect(res.supported).toBe(true);
60
+ expect(res.truncated).toBe(false);
61
+ // Locked file present but without a size; total counts only readable bytes.
62
+ expect(res.totalBytes).toBe(4106);
63
+ expect(res.entries).toEqual([
64
+ { path: '.sp00ky-anon', kind: 'directory' },
65
+ { path: '.sp00ky-anon/0000000001', kind: 'file', size: 4096 },
66
+ { path: '.sp00ky-anon/0000000002', kind: 'file' },
67
+ { path: 'other.txt', kind: 'file', size: 10 },
68
+ ]);
69
+ });
70
+
71
+ it('caps the listing and flags truncation', async () => {
72
+ const big: FakeTree = {};
73
+ for (let i = 0; i < 10; i++) big[`f${i}`] = 1;
74
+ stubOpfs(big);
75
+ const res = await walkOpfs(5);
76
+ expect(res.truncated).toBe(true);
77
+ expect(res.entries.length).toBe(5);
78
+ });
79
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Storage diagnostics for the DevTools Storage tab: what engine backs the
3
+ * local cache, whether it actually persists, how much of the device's quota
4
+ * the origin uses, and what is physically sitting in OPFS. Assembled by
5
+ * `DevToolsService.getStorageInfo()`; everything here is JSON-safe.
6
+ */
7
+
8
+ export interface OpfsEntry {
9
+ /** Path relative to the OPFS root, e.g. `.sp00ky-anon/0000000000000001`. */
10
+ path: string;
11
+ kind: 'file' | 'directory';
12
+ /** Absent when the file's size can't be read (e.g. an exclusive sync access
13
+ * handle is held on it — exactly the case during SAHPool contention). */
14
+ size?: number;
15
+ }
16
+
17
+ /** Engine-side numbers only the engine can produce (worker round-trips). */
18
+ export interface EngineStorageDiagnostics {
19
+ engine: 'sqlite';
20
+ bucketId: string;
21
+ useOpfs: boolean;
22
+ workerSelectConfigured: boolean;
23
+ /** `false` while configured `true` means the runtime downgraded to the
24
+ * legacy multi-hop select (stale cached worker bundle). */
25
+ workerSelectEffective: boolean;
26
+ /** page_count * page_size. */
27
+ dbSizeBytes?: number;
28
+ /** freelist_count * page_size — reclaimable via VACUUM. */
29
+ freelistBytes?: number;
30
+ tableCounts?: { table: string; rows: number }[];
31
+ error?: string;
32
+ }
33
+
34
+ export interface StorageInfo {
35
+ at: number;
36
+ engine: { kind: 'surrealdb' | 'sqlite' | 'custom'; store: string; bucketId: string };
37
+ health: { status: 'unknown' | 'persistent' | 'memory'; fallback: boolean; error?: string };
38
+ browser: {
39
+ /** `navigator.storage.persisted()` — whether the origin's storage is
40
+ * exempt from eviction (unrelated to the OPFS pool lock). */
41
+ persisted?: boolean;
42
+ usage?: number;
43
+ quota?: number;
44
+ /** Chrome-only per-system breakdown from `estimate()`. */
45
+ usageDetails?: Record<string, number>;
46
+ error?: string;
47
+ };
48
+ opfs: {
49
+ supported: boolean;
50
+ entries: OpfsEntry[];
51
+ totalBytes: number;
52
+ truncated: boolean;
53
+ error?: string;
54
+ };
55
+ /** Snapshot of `globalThis.__sqliteStats` (SQLite engine only). */
56
+ sqliteStats?: Record<string, unknown>;
57
+ engineDiagnostics?: EngineStorageDiagnostics;
58
+ }
59
+
60
+ /**
61
+ * Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
62
+ * which throws for a file another context holds an exclusive sync access
63
+ * handle on — SAHPool does exactly that for its whole pool, so a locked file
64
+ * (size omitted) is a live "who has the pool" signal, not a failure.
65
+ */
66
+ export async function walkOpfs(maxEntries = 2000, maxDepth = 8): Promise<StorageInfo['opfs']> {
67
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
68
+ if (!nav?.storage?.getDirectory) {
69
+ return { supported: false, entries: [], totalBytes: 0, truncated: false };
70
+ }
71
+ const entries: OpfsEntry[] = [];
72
+ let totalBytes = 0;
73
+ let truncated = false;
74
+ try {
75
+ const root = await nav.storage.getDirectory();
76
+ const walk = async (dir: FileSystemDirectoryHandle, prefix: string, depth: number) => {
77
+ if (depth > maxDepth) return;
78
+ // entries() is standard; older lib.dom typings may lack it.
79
+ for await (const [name, handle] of (dir as any).entries() as AsyncIterable<
80
+ [string, FileSystemHandle]
81
+ >) {
82
+ if (entries.length >= maxEntries) {
83
+ truncated = true;
84
+ return;
85
+ }
86
+ const path = prefix ? `${prefix}/${name}` : name;
87
+ if (handle.kind === 'directory') {
88
+ entries.push({ path, kind: 'directory' });
89
+ await walk(handle as FileSystemDirectoryHandle, path, depth + 1);
90
+ } else {
91
+ let size: number | undefined;
92
+ try {
93
+ size = (await (handle as FileSystemFileHandle).getFile()).size;
94
+ totalBytes += size;
95
+ } catch {
96
+ // Locked by an exclusive access handle (e.g. a live SAHPool).
97
+ }
98
+ const entry: OpfsEntry = { path, kind: 'file' };
99
+ if (size !== undefined) entry.size = size;
100
+ entries.push(entry);
101
+ }
102
+ }
103
+ };
104
+ await walk(root, '', 0);
105
+ entries.sort((a, b) => a.path.localeCompare(b.path));
106
+ return { supported: true, entries, totalBytes, truncated };
107
+ } catch (e) {
108
+ return {
109
+ supported: true,
110
+ entries,
111
+ totalBytes,
112
+ truncated,
113
+ error: e instanceof Error ? e.message : String(e),
114
+ };
115
+ }
116
+ }
@@ -2,6 +2,7 @@ import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-buil
2
2
  import type { SealedQuery } from '../../utils/surql';
3
3
  import type { DatabaseEventSystem } from './events/index';
4
4
  import type { Sp00kyConfig, StorageHealth } from '../../types';
5
+ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
5
6
 
6
7
  /**
7
8
  * A materialized row. Keys are field names; values are already decoded to the
@@ -121,6 +122,13 @@ export interface LocalStore extends LocalCacheEngine {
121
122
  getClient(): unknown;
122
123
  getConfig(): Sp00kyConfig<any>['database'];
123
124
  readonly currentBucketId: string;
125
+ /** Which built-in backend this is. OPTIONAL: absent (custom engines) is
126
+ * reported as `'custom'` by DevTools. More robust than `instanceof` for
127
+ * engines constructed outside this package. */
128
+ readonly engineKind?: 'surrealdb' | 'sqlite';
129
+ /** Engine-specific storage numbers for DevTools (DB file size, per-table
130
+ * row counts). OPTIONAL: only engines with something to report implement it. */
131
+ getStorageDiagnostics?(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics>;
124
132
  /**
125
133
  * Durability of this engine's local store. OPTIONAL: engines that don't
126
134
  * report it (SurrealDB, custom engines) are treated as `'unknown'` by the
@@ -222,6 +222,78 @@ describe('SqliteCacheEngine storage health', () => {
222
222
  });
223
223
  });
224
224
 
225
+ // Storage numbers for the DevTools Storage tab: DB size via the pragmas, row
226
+ // counts on demand, and the configured-vs-effective workerSelect split. Errors
227
+ // must land in `error` (the worker may be mid bucket-switch), never throw.
228
+ describe('SqliteCacheEngine.getStorageDiagnostics', () => {
229
+ function makeEngine(execRows: (sql: string) => unknown[]) {
230
+ const noop = () => {};
231
+ const logger: any = { debug: noop, info: noop, warn: noop, error: noop, trace: noop };
232
+ logger.child = () => logger;
233
+ const engine = new SqliteCacheEngine({ namespace: 'n', database: 'd' } as any, logger);
234
+ (engine as any).spawnWorker = () => {
235
+ const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
236
+ w.postMessage = (msg: any) => {
237
+ Promise.resolve().then(() => {
238
+ const rest =
239
+ msg.type === 'open'
240
+ ? { persisted: true }
241
+ : msg.type === 'exec'
242
+ ? { rows: execRows(msg.payload.sql) }
243
+ : {};
244
+ const p = (engine as any).pending.get(msg.id);
245
+ if (!p) return;
246
+ (engine as any).pending.delete(msg.id);
247
+ p.resolve(rest);
248
+ });
249
+ };
250
+ return w as unknown as Worker;
251
+ };
252
+ return engine;
253
+ }
254
+
255
+ it('reports size, freelist, and per-table counts', async () => {
256
+ const engine = makeEngine((sql) => {
257
+ if (sql.includes('pragma_page_count')) return [{ bytes: 40960, freelist: 4096 }];
258
+ if (sql.includes('sqlite_master')) return [{ name: '_00_query' }, { name: 'game' }];
259
+ if (sql.includes('COUNT(*)'))
260
+ return [
261
+ { t: '_00_query', n: 3 },
262
+ { t: 'game', n: 12 },
263
+ ];
264
+ return [];
265
+ });
266
+ await engine.connect('user:abc');
267
+
268
+ const diag = await engine.getStorageDiagnostics({ tableCounts: true });
269
+ expect(diag.engine).toBe('sqlite');
270
+ expect(diag.bucketId).toBe('user:abc');
271
+ expect(diag.dbSizeBytes).toBe(40960);
272
+ expect(diag.freelistBytes).toBe(4096);
273
+ expect(diag.tableCounts).toEqual([
274
+ { table: '_00_query', rows: 3 },
275
+ { table: 'game', rows: 12 },
276
+ ]);
277
+ // Default config: workerSelect on, never downgraded.
278
+ expect(diag.workerSelectConfigured).toBe(true);
279
+ expect(diag.workerSelectEffective).toBe(true);
280
+ });
281
+
282
+ it('skips table counts unless asked and never throws on a dead worker', async () => {
283
+ const engine = makeEngine(() => [{ bytes: 8192, freelist: 0 }]);
284
+ await engine.connect('anon');
285
+
286
+ const diag = await engine.getStorageDiagnostics();
287
+ expect(diag.tableCounts).toBeUndefined();
288
+
289
+ // No worker at all → the failure lands in `error`, not as a throw.
290
+ const cold = makeEngine(() => []);
291
+ const coldDiag = await cold.getStorageDiagnostics();
292
+ expect(coldDiag.error).toContain('not connected');
293
+ expect(coldDiag.bucketId).toBe('anon');
294
+ });
295
+ });
296
+
225
297
  // `pureWriteOpResult` is the single source of truth for what a pure-write op
226
298
  // contributes to a query's per-statement results. The batched fast path in
227
299
  // `query()` and the per-op `execOp` path BOTH route through it, so a caller that
@@ -19,6 +19,7 @@ import {
19
19
  import { StaleEpochError } from './local';
20
20
  import { translateSurql, tableOf, setPath, getPath, type SqlOp } from './surql-translate';
21
21
  import type { EngineTx, Id, LocalStore, OrderBy, RelationFetch, Row } from './cache-engine';
22
+ import type { EngineStorageDiagnostics } from '../../modules/devtools/storage-info';
22
23
 
23
24
  /**
24
25
  * The statement result a pure-write op contributes to a query's results array.
@@ -92,6 +93,9 @@ export class SqliteCacheEngine implements LocalStore {
92
93
  * Flipped off at runtime if the worker script predates the `select` op
93
94
  * (stale cached bundle) — degrade to the legacy multi-hop path, don't break. */
94
95
  private workerSelect: boolean;
96
+ /** What `workerSelect` was at construction, so DevTools can tell a runtime
97
+ * downgrade (configured true, effective false) from a configured-off. */
98
+ private workerSelectConfigured: boolean;
95
99
  private events: DatabaseEventSystem = createDatabaseEventSystem();
96
100
  private bucketId = 'anon';
97
101
  /** Durability of the local store, set on every open. A plain Set of callbacks
@@ -102,6 +106,8 @@ export class SqliteCacheEngine implements LocalStore {
102
106
  /** Schemaless — tables are created lazily on first write; no migrator. */
103
107
  readonly usesSurqlSchema = false;
104
108
 
109
+ readonly engineKind = 'sqlite' as const;
110
+
105
111
  constructor(
106
112
  private config: Sp00kyConfig<any>['database'],
107
113
  private logger: Logger,
@@ -109,6 +115,7 @@ export class SqliteCacheEngine implements LocalStore {
109
115
  ) {
110
116
  this.useOpfs = opts.useOpfs ?? true;
111
117
  this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
118
+ this.workerSelectConfigured = this.workerSelect;
112
119
  }
113
120
 
114
121
  get epoch(): number {
@@ -143,6 +150,51 @@ export class SqliteCacheEngine implements LocalStore {
143
150
  return this.config;
144
151
  }
145
152
 
153
+ /**
154
+ * Storage numbers for the DevTools Storage tab. Uses {@link call} so the
155
+ * reads serialize with regular traffic (no SQLITE_BUSY). Never throws — the
156
+ * worker may be mid bucket-switch; a failure lands in `error` instead.
157
+ */
158
+ async getStorageDiagnostics(opts?: { tableCounts?: boolean }): Promise<EngineStorageDiagnostics> {
159
+ const diag: EngineStorageDiagnostics = {
160
+ engine: 'sqlite',
161
+ bucketId: this.bucketId,
162
+ useOpfs: this.useOpfs,
163
+ workerSelectConfigured: this.workerSelectConfigured,
164
+ workerSelectEffective: this.workerSelect,
165
+ };
166
+ try {
167
+ const { rows } = await this.call<{ rows: { bytes: number; freelist: number }[] }>('exec', {
168
+ sql:
169
+ 'SELECT (SELECT * FROM pragma_page_count()) * (SELECT * FROM pragma_page_size()) AS bytes, ' +
170
+ '(SELECT * FROM pragma_freelist_count()) * (SELECT * FROM pragma_page_size()) AS freelist',
171
+ });
172
+ diag.dbSizeBytes = rows?.[0]?.bytes;
173
+ diag.freelistBytes = rows?.[0]?.freelist;
174
+ if (opts?.tableCounts) {
175
+ const { rows: tables } = await this.call<{ rows: { name: string }[] }>('exec', {
176
+ sql: "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name",
177
+ });
178
+ const names = (tables ?? []).map((r) => r.name);
179
+ if (names.length) {
180
+ // Names come from sqlite_master itself; double-quoting is enough.
181
+ const sql = names
182
+ .map((n) => `SELECT '${n.replace(/'/g, "''")}' AS t, COUNT(*) AS n FROM "${n.replace(/"/g, '""')}"`)
183
+ .join(' UNION ALL ');
184
+ const { rows: counts } = await this.call<{ rows: { t: string; n: number }[] }>('exec', {
185
+ sql,
186
+ });
187
+ diag.tableCounts = (counts ?? []).map((r) => ({ table: r.t, rows: r.n }));
188
+ } else {
189
+ diag.tableCounts = [];
190
+ }
191
+ }
192
+ } catch (e) {
193
+ diag.error = e instanceof Error ? e.message : String(e);
194
+ }
195
+ return diag;
196
+ }
197
+
146
198
  getEvents(): DatabaseEventSystem {
147
199
  return this.events;
148
200
  }
@@ -290,14 +342,18 @@ export class SqliteCacheEngine implements LocalStore {
290
342
  // (the worker also console.errors, since host apps may run pino at `fatal`)
291
343
  // and publish it so the app can warn the user.
292
344
  const fellBack = this.useOpfs && !persisted;
293
- this.setStorageHealth({
345
+ // Omit `error` rather than setting it to `undefined`: the devtools
346
+ // serializer renders an undefined value as the STRING 'undefined'.
347
+ const health: StorageHealth = {
294
348
  status: persisted ? 'persistent' : 'memory',
295
349
  fallback: fellBack,
296
- error: fellBack ? opfsError : undefined,
297
- });
350
+ };
351
+ if (fellBack && opfsError) health.error = opfsError;
352
+ this.setStorageHealth(health);
298
353
  const stats = getStats();
299
354
  stats.persisted = persisted;
300
- stats.opfsError = fellBack ? opfsError : undefined;
355
+ if (fellBack && opfsError) stats.opfsError = opfsError;
356
+ else delete stats.opfsError;
301
357
  if (fellBack) {
302
358
  this.logger.error(
303
359
  { bucketId, opfsError, Category: 'sp00ky-client::SqliteCacheEngine::connect' },
@@ -31,6 +31,8 @@ export class SurrealCacheEngine extends LocalDatabaseService implements LocalCac
31
31
  /** SurrealDB needs its SurrealQL schema provisioned locally. */
32
32
  readonly usesSurqlSchema = true;
33
33
 
34
+ readonly engineKind = 'surrealdb' as const;
35
+
34
36
  /** {@link LocalCacheEngine} alias for {@link LocalDatabaseService.switchStore}. */
35
37
  switchBucket(bucketId: string): Promise<void> {
36
38
  return this.switchStore(bucketId);