@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.
- package/dist/index.d.ts +11 -2
- package/dist/index.js +251 -7
- package/dist/sqlite-worker.js +69 -11
- package/dist/types.d.ts +65 -1
- package/package.json +3 -3
- package/src/modules/devtools/index.ts +81 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +116 -0
- package/src/services/database/cache-engine.ts +18 -1
- package/src/services/database/sqlite-cache-engine.test.ts +159 -0
- package/src/services/database/sqlite-cache-engine.ts +118 -6
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +129 -0
- package/src/services/database/sqlite-worker.ts +12 -20
- package/src/services/database/surreal-cache-engine.ts +2 -0
- package/src/sp00ky.ts +28 -1
- package/src/types.ts +27 -0
|
@@ -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
|