@spooky-sync/core 0.0.1-canary.152 → 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.
@@ -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
@@ -96,6 +97,11 @@ export class DevToolsService implements StreamUpdateReceiver {
96
97
  }
97
98
  });
98
99
 
100
+ // Push state when the local store reports its durability (the open happens
101
+ // during connect, typically before a panel attaches, so this mostly matters
102
+ // for a later bucket switch that loses OPFS).
103
+ this.databaseService.subscribeToStorageHealth?.(() => this.notifyDevTools());
104
+
99
105
  // Fire-and-forget backend version discovery; re-push state when it lands.
100
106
  void this.refreshBackendVersions();
101
107
 
@@ -318,10 +324,79 @@ export class DevToolsService implements StreamUpdateReceiver {
318
324
  ? this.localTables
319
325
  : this.schema.tables.map((t) => t.name),
320
326
  tableData: {},
327
+ // Durability of the local store. `fallback: true` means persistence was
328
+ // requested but the dataset is actually sitting in RAM.
329
+ storage: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
321
330
  },
322
331
  });
323
332
  }
324
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
+
325
400
  private notifyDevTools() {
326
401
  // No consumer attached → no getState() serialization, no postMessage broadcast.
327
402
  if (!this.enabled) return;
@@ -375,6 +450,10 @@ export class DevToolsService implements StreamUpdateReceiver {
375
450
  const result: Record<string, any> = {};
376
451
  for (const key in data) {
377
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;
378
457
  result[key] = this.serializeForDevTools(data[key], seen);
379
458
  }
380
459
  }
@@ -394,6 +473,8 @@ export class DevToolsService implements StreamUpdateReceiver {
394
473
  this.notifyDevTools();
395
474
  },
396
475
  refreshVersions: () => this.refreshBackendVersions(),
476
+ getStorageInfo: (opts?: { tableCounts?: boolean }) => this.getStorageInfo(opts),
477
+ requestPersistentStorage: () => this.requestPersistentStorage(),
397
478
  getTableData: async (tableName: string) => {
398
479
  try {
399
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
+ }
@@ -1,7 +1,8 @@
1
1
  import type { QueryPlan, RelationPlan, WhereNode } from '@spooky-sync/query-builder';
2
2
  import type { SealedQuery } from '../../utils/surql';
3
3
  import type { DatabaseEventSystem } from './events/index';
4
- import type { Sp00kyConfig } from '../../types';
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,22 @@ 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>;
132
+ /**
133
+ * Durability of this engine's local store. OPTIONAL: engines that don't
134
+ * report it (SurrealDB, custom engines) are treated as `'unknown'` by the
135
+ * client facade, so adding this needs no change on their side.
136
+ */
137
+ readonly storageHealth?: StorageHealth;
138
+ /** Fires immediately with the current snapshot, then on every change.
139
+ * Returns an unsubscribe function. */
140
+ subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
124
141
  }
125
142
 
126
143
  /** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
@@ -135,6 +135,165 @@ describe('SqliteCacheEngine system-table seeding', () => {
135
135
  });
136
136
  });
137
137
 
138
+ // The worker's `persisted`/`opfsError` reply used to die in a `logger.info`
139
+ // line, so a host app running pino at `fatal` (whitepawn does) could not tell a
140
+ // disk-backed store from a full-RAM one. It now lands on the engine as
141
+ // observable state the app can render.
142
+ describe('SqliteCacheEngine storage health', () => {
143
+ /** Engine wired to a worker whose `open` replies with `openReply`. */
144
+ function makeEngine(openReply: Record<string, unknown>, opts?: { useOpfs?: boolean }) {
145
+ const logs: { level: string; msg: string; meta: any }[] = [];
146
+ const logger: any = {};
147
+ for (const level of ['debug', 'info', 'warn', 'error', 'trace']) {
148
+ logger[level] = (meta: any, msg: string) => logs.push({ level, msg, meta });
149
+ }
150
+ logger.child = () => logger;
151
+
152
+ const engine = new SqliteCacheEngine(
153
+ { namespace: 'n', database: 'd' } as any,
154
+ logger,
155
+ opts ?? {}
156
+ );
157
+ (engine as any).spawnWorker = () => {
158
+ const w: any = { onmessage: null, onerror: null, onmessageerror: null, terminate() {} };
159
+ w.postMessage = (msg: any) => {
160
+ Promise.resolve().then(() => {
161
+ const rest = msg.type === 'open' ? openReply : {};
162
+ const { id, ok, error, ...payload } = { id: msg.id, ok: true, error: undefined, ...rest };
163
+ const p = (engine as any).pending.get(id);
164
+ if (!p) return;
165
+ (engine as any).pending.delete(id);
166
+ if (ok) p.resolve(payload);
167
+ else p.reject(new Error(error));
168
+ });
169
+ };
170
+ return w as unknown as Worker;
171
+ };
172
+ return { engine, logs };
173
+ }
174
+
175
+ it('publishes a persistent store and logs no error', async () => {
176
+ const { engine, logs } = makeEngine({ persisted: true });
177
+ await engine.connect('user:abc');
178
+
179
+ expect(engine.storageHealth).toEqual({
180
+ status: 'persistent',
181
+ fallback: false,
182
+ error: undefined,
183
+ });
184
+ expect(logs.some((l) => l.level === 'error')).toBe(false);
185
+ });
186
+
187
+ it('publishes the fallback, its reason, and an error log when OPFS is lost', async () => {
188
+ const { engine, logs } = makeEngine({ persisted: false, opfsError: 'NoModificationAllowedError: locked' });
189
+ await engine.connect('user:abc');
190
+
191
+ expect(engine.storageHealth).toEqual({
192
+ status: 'memory',
193
+ fallback: true,
194
+ error: 'NoModificationAllowedError: locked',
195
+ });
196
+ const err = logs.find((l) => l.level === 'error');
197
+ expect(err?.msg).toContain('IN MEMORY');
198
+ expect(err?.meta.opfsError).toBe('NoModificationAllowedError: locked');
199
+ // Inspectable from the console without any logging configured.
200
+ expect((globalThis as any).__sqliteStats.persisted).toBe(false);
201
+ });
202
+
203
+ // A subscriber almost always attaches AFTER connect() (components mount
204
+ // later), so an immediate fire is the only way it learns about a fallback.
205
+ it('fires a late subscriber with the current snapshot', async () => {
206
+ const { engine } = makeEngine({ persisted: false, opfsError: 'boom' });
207
+ await engine.connect('user:abc');
208
+
209
+ const seen: any[] = [];
210
+ const unsub = engine.subscribeToStorageHealth((h) => seen.push(h));
211
+ expect(seen).toEqual([{ status: 'memory', fallback: true, error: 'boom' }]);
212
+ unsub();
213
+ });
214
+
215
+ // `store: 'memory'` asked for RAM, so it is not a fallback and must not warn.
216
+ it('does not flag a configured in-memory store as a fallback', async () => {
217
+ const { engine, logs } = makeEngine({ persisted: false }, { useOpfs: false });
218
+ await engine.connect('user:abc');
219
+
220
+ expect(engine.storageHealth).toEqual({ status: 'memory', fallback: false, error: undefined });
221
+ expect(logs.some((l) => l.level === 'error')).toBe(false);
222
+ });
223
+ });
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
+
138
297
  // `pureWriteOpResult` is the single source of truth for what a pure-write op
139
298
  // contributes to a query's per-statement results. The batched fast path in
140
299
  // `query()` and the per-op `execOp` path BOTH route through it, so a caller that