@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 +187 -8
- package/dist/types.d.ts +30 -0
- package/package.json +3 -3
- package/src/modules/devtools/index.ts +73 -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 +8 -0
- package/src/services/database/sqlite-cache-engine.test.ts +72 -0
- package/src/services/database/sqlite-cache-engine.ts +60 -4
- package/src/services/database/surreal-cache-engine.ts +2 -0
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
|
-
|
|
1702
|
+
const health = {
|
|
1663
1703
|
status: persisted ? "persistent" : "memory",
|
|
1664
|
-
fallback: fellBack
|
|
1665
|
-
|
|
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
|
-
|
|
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.
|
|
5094
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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))
|
|
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.
|
|
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.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|