@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
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as StorageHealthStatus, B as DatabaseEventSystem, C as RecordVersionDiff, D as Sp00kyQueryResult, E as Sp00kyConfig, F as TimingPhase, G as EventSystem, H as Logger$1, I as UpdateOptions, L as UpEvent, M as SyncHealth, N as SyncHealthConfig, O as Sp00kyQueryResultPromise, P as SyncHealthStatus, R as LocalStore, S as RecordVersionArray, T as RunOptions, U as SyncEventSystem, V as DatabaseEventTypes, W as EventDefinition, _ as QueryStatus, a as MutationCallback, b as QueryTimings, c as PersistenceClient, d as PreloadOptions, f as PreloadRefresh, g as QueryState, h as QueryHash, i as MATERIALIZATION_SAMPLE_WINDOW, j as StoreType, k as StorageHealth, l as PhaseStat, m as QueryConfigRecord, n as EventSubscriptionOptions, o as MutationEvent, p as QueryConfig, r as Level, s as MutationEventType, t as DebounceOptions, u as PinoTransmit, v as QueryStatusCallback, w as RegistrationTimings, x as QueryUpdateCallback, y as QueryTimeToLive, z as SealedQuery } from "./types.js";
|
|
2
2
|
import * as surrealdb0 from "surrealdb";
|
|
3
3
|
import { Duration, RecordId, Surreal as Surreal$1, SurrealTransaction } from "surrealdb";
|
|
4
4
|
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
@@ -1265,6 +1265,15 @@ declare class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1265
1265
|
* on every healthy↔degraded transition. Returns an unsubscribe.
|
|
1266
1266
|
*/
|
|
1267
1267
|
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void;
|
|
1268
|
+
/** Durability of the local cache. See {@link StorageHealth}. `'unknown'` for
|
|
1269
|
+
* engines that don't report it. */
|
|
1270
|
+
get storageHealth(): StorageHealth;
|
|
1271
|
+
/**
|
|
1272
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
1273
|
+
* and again on every change (at most once per bucket open in practice).
|
|
1274
|
+
* Returns an unsubscribe.
|
|
1275
|
+
*/
|
|
1276
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void;
|
|
1268
1277
|
constructor(config: Sp00kyConfig<S>);
|
|
1269
1278
|
/**
|
|
1270
1279
|
* Setup direct callbacks instead of event subscriptions
|
|
@@ -1428,4 +1437,4 @@ declare function textToHtml(text: string): string;
|
|
|
1428
1437
|
*/
|
|
1429
1438
|
|
|
1430
1439
|
//#endregion
|
|
1431
|
-
export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
|
|
1440
|
+
export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
|
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,15 +1510,28 @@ 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";
|
|
1518
|
+
/** Durability of the local store, set on every open. A plain Set of callbacks
|
|
1519
|
+
* rather than a `DatabaseEventSystem` event: this changes at most once per
|
|
1520
|
+
* open, and the typed event map is about query traffic. */
|
|
1521
|
+
storageHealthValue = {
|
|
1522
|
+
status: "unknown",
|
|
1523
|
+
fallback: false
|
|
1524
|
+
};
|
|
1525
|
+
storageHealthSubs = /* @__PURE__ */ new Set();
|
|
1514
1526
|
/** Schemaless — tables are created lazily on first write; no migrator. */
|
|
1515
1527
|
usesSurqlSchema = false;
|
|
1528
|
+
engineKind = "sqlite";
|
|
1516
1529
|
constructor(config, logger, opts = {}) {
|
|
1517
1530
|
this.config = config;
|
|
1518
1531
|
this.logger = logger;
|
|
1519
1532
|
this.useOpfs = opts.useOpfs ?? true;
|
|
1520
1533
|
this.workerSelect = opts.workerSelect ?? config.workerSelect ?? true;
|
|
1534
|
+
this.workerSelectConfigured = this.workerSelect;
|
|
1521
1535
|
}
|
|
1522
1536
|
get epoch() {
|
|
1523
1537
|
return this.storeEpoch;
|
|
@@ -1525,9 +1539,60 @@ var SqliteCacheEngine = class {
|
|
|
1525
1539
|
get currentBucketId() {
|
|
1526
1540
|
return this.bucketId;
|
|
1527
1541
|
}
|
|
1542
|
+
get storageHealth() {
|
|
1543
|
+
return this.storageHealthValue;
|
|
1544
|
+
}
|
|
1545
|
+
/** Fires immediately with the current snapshot (the store opens during
|
|
1546
|
+
* `connect()`, before app components mount, so a late subscriber must still
|
|
1547
|
+
* learn a fallback happened), then on every change. */
|
|
1548
|
+
subscribeToStorageHealth(cb) {
|
|
1549
|
+
cb(this.storageHealthValue);
|
|
1550
|
+
this.storageHealthSubs.add(cb);
|
|
1551
|
+
return () => {
|
|
1552
|
+
this.storageHealthSubs.delete(cb);
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
setStorageHealth(health) {
|
|
1556
|
+
this.storageHealthValue = health;
|
|
1557
|
+
for (const cb of this.storageHealthSubs) cb(health);
|
|
1558
|
+
}
|
|
1528
1559
|
getConfig() {
|
|
1529
1560
|
return this.config;
|
|
1530
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
|
+
}
|
|
1531
1596
|
getEvents() {
|
|
1532
1597
|
return this.events;
|
|
1533
1598
|
}
|
|
@@ -1625,7 +1690,7 @@ var SqliteCacheEngine = class {
|
|
|
1625
1690
|
*/
|
|
1626
1691
|
async openInternal(bucketId) {
|
|
1627
1692
|
this.worker = this.spawnWorker();
|
|
1628
|
-
const { persisted } = await this.rawCall("open", {
|
|
1693
|
+
const { persisted, opfsError } = await this.rawCall("open", {
|
|
1629
1694
|
dbName: bucketId,
|
|
1630
1695
|
useOpfs: this.useOpfs,
|
|
1631
1696
|
systemTables: SYSTEM_TABLES
|
|
@@ -1633,11 +1698,27 @@ var SqliteCacheEngine = class {
|
|
|
1633
1698
|
this.knownTables.clear();
|
|
1634
1699
|
for (const t of SYSTEM_TABLES) this.knownTables.add(t);
|
|
1635
1700
|
this.bucketId = bucketId;
|
|
1636
|
-
this.
|
|
1701
|
+
const fellBack = this.useOpfs && !persisted;
|
|
1702
|
+
const health = {
|
|
1703
|
+
status: persisted ? "persistent" : "memory",
|
|
1704
|
+
fallback: fellBack
|
|
1705
|
+
};
|
|
1706
|
+
if (fellBack && opfsError) health.error = opfsError;
|
|
1707
|
+
this.setStorageHealth(health);
|
|
1708
|
+
const stats = getStats();
|
|
1709
|
+
stats.persisted = persisted;
|
|
1710
|
+
if (fellBack && opfsError) stats.opfsError = opfsError;
|
|
1711
|
+
else delete stats.opfsError;
|
|
1712
|
+
if (fellBack) this.logger.error({
|
|
1713
|
+
bucketId,
|
|
1714
|
+
opfsError,
|
|
1715
|
+
Category: "sp00ky-client::SqliteCacheEngine::connect"
|
|
1716
|
+
}, "SQLite OPFS persistence failed; store is IN MEMORY and will not survive reload");
|
|
1717
|
+
else this.logger.info({
|
|
1637
1718
|
bucketId,
|
|
1638
1719
|
persisted,
|
|
1639
1720
|
Category: "sp00ky-client::SqliteCacheEngine::connect"
|
|
1640
|
-
}, persisted ? "SQLite OPFS store opened" : "SQLite in-memory store opened (
|
|
1721
|
+
}, persisted ? "SQLite OPFS store opened" : "SQLite in-memory store opened (as configured)");
|
|
1641
1722
|
}
|
|
1642
1723
|
/** Enqueue `fn` as a single serialized opQueue entry (mirrors {@link call}'s
|
|
1643
1724
|
* chaining) so it can't interleave with reads/writes at the worker. */
|
|
@@ -5049,10 +5130,79 @@ function parseBackendInfo(raw) {
|
|
|
5049
5130
|
};
|
|
5050
5131
|
}
|
|
5051
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
|
+
|
|
5052
5202
|
//#endregion
|
|
5053
5203
|
//#region src/modules/devtools/index.ts
|
|
5054
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
5055
|
-
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";
|
|
5056
5206
|
const SURREAL_VERSION = "3.0.3";
|
|
5057
5207
|
var DevToolsService = class {
|
|
5058
5208
|
eventsHistory = [];
|
|
@@ -5083,6 +5233,7 @@ var DevToolsService = class {
|
|
|
5083
5233
|
if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) this.refreshBackendVersions();
|
|
5084
5234
|
else this.notifyDevTools();
|
|
5085
5235
|
});
|
|
5236
|
+
this.databaseService.subscribeToStorageHealth?.(() => this.notifyDevTools());
|
|
5086
5237
|
this.refreshBackendVersions();
|
|
5087
5238
|
this.logger.debug({ Category: "sp00ky-client::DevToolsService::init" }, "Service initialized");
|
|
5088
5239
|
}
|
|
@@ -5248,10 +5399,77 @@ var DevToolsService = class {
|
|
|
5248
5399
|
},
|
|
5249
5400
|
database: {
|
|
5250
5401
|
tables: this.localTables.length ? this.localTables : this.schema.tables.map((t) => t.name),
|
|
5251
|
-
tableData: {}
|
|
5402
|
+
tableData: {},
|
|
5403
|
+
storage: this.databaseService.storageHealth ?? {
|
|
5404
|
+
status: "unknown",
|
|
5405
|
+
fallback: false
|
|
5406
|
+
}
|
|
5252
5407
|
}
|
|
5253
5408
|
});
|
|
5254
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
|
+
}
|
|
5255
5473
|
notifyDevTools() {
|
|
5256
5474
|
if (!this.enabled) return;
|
|
5257
5475
|
if (typeof window !== "undefined") window.postMessage({
|
|
@@ -5275,7 +5493,10 @@ var DevToolsService = class {
|
|
|
5275
5493
|
if (seen.has(data)) return "[Circular Object]";
|
|
5276
5494
|
seen.add(data);
|
|
5277
5495
|
const result = {};
|
|
5278
|
-
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
|
+
}
|
|
5279
5500
|
return result;
|
|
5280
5501
|
}
|
|
5281
5502
|
return data;
|
|
@@ -5290,6 +5511,8 @@ var DevToolsService = class {
|
|
|
5290
5511
|
this.notifyDevTools();
|
|
5291
5512
|
},
|
|
5292
5513
|
refreshVersions: () => this.refreshBackendVersions(),
|
|
5514
|
+
getStorageInfo: (opts) => this.getStorageInfo(opts),
|
|
5515
|
+
requestPersistentStorage: () => this.requestPersistentStorage(),
|
|
5293
5516
|
getTableData: async (tableName) => {
|
|
5294
5517
|
try {
|
|
5295
5518
|
const result = await this.databaseService.query(`SELECT * FROM ${tableName}`);
|
|
@@ -7159,6 +7382,12 @@ var BucketHandle = class {
|
|
|
7159
7382
|
* callback switches to the user's bucket (cache + outbox intact).
|
|
7160
7383
|
*/
|
|
7161
7384
|
const LAST_BUCKET_KEY = "sp00ky:last_bucket";
|
|
7385
|
+
/** Reported for engines that don't track local-store durability. Frozen so a
|
|
7386
|
+
* subscriber can't mutate the shared snapshot. */
|
|
7387
|
+
const UNKNOWN_STORAGE_HEALTH = Object.freeze({
|
|
7388
|
+
status: "unknown",
|
|
7389
|
+
fallback: false
|
|
7390
|
+
});
|
|
7162
7391
|
function readBootBucketHint() {
|
|
7163
7392
|
try {
|
|
7164
7393
|
return typeof localStorage !== "undefined" ? localStorage.getItem(LAST_BUCKET_KEY) : null;
|
|
@@ -7219,6 +7448,21 @@ var Sp00kyClient = class {
|
|
|
7219
7448
|
subscribeToSyncHealth(cb) {
|
|
7220
7449
|
return this.sync.subscribeToSyncHealth(cb);
|
|
7221
7450
|
}
|
|
7451
|
+
/** Durability of the local cache. See {@link StorageHealth}. `'unknown'` for
|
|
7452
|
+
* engines that don't report it. */
|
|
7453
|
+
get storageHealth() {
|
|
7454
|
+
return this.local.storageHealth ?? UNKNOWN_STORAGE_HEALTH;
|
|
7455
|
+
}
|
|
7456
|
+
/**
|
|
7457
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
7458
|
+
* and again on every change (at most once per bucket open in practice).
|
|
7459
|
+
* Returns an unsubscribe.
|
|
7460
|
+
*/
|
|
7461
|
+
subscribeToStorageHealth(cb) {
|
|
7462
|
+
if (this.local.subscribeToStorageHealth) return this.local.subscribeToStorageHealth(cb);
|
|
7463
|
+
cb(UNKNOWN_STORAGE_HEALTH);
|
|
7464
|
+
return () => {};
|
|
7465
|
+
}
|
|
7222
7466
|
constructor(config) {
|
|
7223
7467
|
this.config = config;
|
|
7224
7468
|
const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
|
package/dist/sqlite-worker.js
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
import { i as reviveRow, n as renderOrderSql, o as resolveRelations, r as renderWhereSql, s as stableKey, t as project } from "./sqlite-plan-sql.js";
|
|
2
2
|
import sqlite3InitModule from "@sqlite.org/sqlite-wasm";
|
|
3
3
|
|
|
4
|
+
//#region src/services/database/sqlite-open.ts
|
|
5
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
6
|
+
/** Bounded on purpose: this runs on the boot path, before the first query. */
|
|
7
|
+
const DEFAULT_BACKOFF_MS = [250, 500];
|
|
8
|
+
/** Failures no retry can fix: the APIs aren't there at all (insecure context,
|
|
9
|
+
* or a browser without sync access handles). Fall back immediately. */
|
|
10
|
+
const UNRETRYABLE = ["Missing required OPFS APIs"];
|
|
11
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
/** Keep the DOMException name (e.g. `NoModificationAllowedError` for a pool
|
|
13
|
+
* locked by another tab): it is the most diagnostic part of the failure. */
|
|
14
|
+
function errMessage(e) {
|
|
15
|
+
if (e instanceof Error) return e.name && e.name !== "Error" ? `${e.name}: ${e.message}` : e.message;
|
|
16
|
+
return String(e);
|
|
17
|
+
}
|
|
18
|
+
function fallbackToMemory(sqlite3, dbName, reason, attempts) {
|
|
19
|
+
const tried = attempts > 0 ? ` after ${attempts} attempt${attempts === 1 ? "" : "s"}` : "";
|
|
20
|
+
console.error(`[sp00ky] OPFS persistence unavailable for "${dbName}"${tried}: ${reason}. The local SQLite cache is running IN MEMORY, which keeps the whole dataset in RAM and loses every local write on reload. The usual cause is another tab of this app holding the storage lock, so closing the other tabs and reloading restores persistence.`);
|
|
21
|
+
return {
|
|
22
|
+
db: new sqlite3.oo1.DB(":memory:", "c"),
|
|
23
|
+
persisted: false,
|
|
24
|
+
opfsError: reason
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Open `dbName`'s handle. Never throws for a storage problem: a caller that
|
|
29
|
+
* asked for persistence and can't have it gets a working in-memory handle plus
|
|
30
|
+
* `persisted: false` and an `opfsError` to report.
|
|
31
|
+
*/
|
|
32
|
+
async function openDb(sqlite3, dbName, useOpfs, opts = {}) {
|
|
33
|
+
if (!useOpfs) return {
|
|
34
|
+
db: new sqlite3.oo1.DB(":memory:", "c"),
|
|
35
|
+
persisted: false
|
|
36
|
+
};
|
|
37
|
+
if (!sqlite3.installOpfsSAHPoolVfs) return fallbackToMemory(sqlite3, dbName, "sqlite-wasm build has no installOpfsSAHPoolVfs", 0);
|
|
38
|
+
const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
39
|
+
const backoffMs = opts.backoffMs ?? DEFAULT_BACKOFF_MS;
|
|
40
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
41
|
+
let lastError = "unknown error";
|
|
42
|
+
let attempts = 0;
|
|
43
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
44
|
+
attempts = attempt;
|
|
45
|
+
try {
|
|
46
|
+
return {
|
|
47
|
+
db: new (await (sqlite3.installOpfsSAHPoolVfs({
|
|
48
|
+
name: `sp00ky-${dbName}`,
|
|
49
|
+
...attempt > 1 ? { forceReinitIfPreviouslyFailed: true } : {}
|
|
50
|
+
}))).OpfsSAHPoolDb(`/${dbName}.sqlite3`),
|
|
51
|
+
persisted: true
|
|
52
|
+
};
|
|
53
|
+
} catch (e) {
|
|
54
|
+
lastError = errMessage(e);
|
|
55
|
+
if (attempt === maxAttempts || UNRETRYABLE.some((m) => lastError.includes(m))) break;
|
|
56
|
+
await sleep(backoffMs[Math.min(attempt - 1, backoffMs.length - 1)] ?? 0);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return fallbackToMemory(sqlite3, dbName, lastError, attempts);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
//#endregion
|
|
4
63
|
//#region src/services/database/sqlite-select.ts
|
|
5
64
|
function ensureTable(db, table) {
|
|
6
65
|
if (db.knownTables.has(table)) return;
|
|
@@ -79,11 +138,12 @@ async function executeSelect(plan, params, db) {
|
|
|
79
138
|
* is also what the OPFS VFS requires — file access must happen off the main
|
|
80
139
|
* thread. Persistence uses the **OPFS SAHPool VFS**: durable, and (unlike the
|
|
81
140
|
* classic OPFS VFS) it does NOT require COOP/COEP cross-origin isolation
|
|
82
|
-
* headers, so host apps embedding the client need no server changes.
|
|
83
|
-
* to an in-memory DB
|
|
141
|
+
* headers, so host apps embedding the client need no server changes. When OPFS
|
|
142
|
+
* is unavailable it retries, then falls back to an in-memory DB and REPORTS the
|
|
143
|
+
* loss of durability (see `sqlite-open.ts`) instead of degrading silently.
|
|
84
144
|
*
|
|
85
145
|
* Message protocol (request/response keyed by `id`):
|
|
86
|
-
* { id, type: 'open', payload: { dbName, useOpfs } }
|
|
146
|
+
* { id, type: 'open', payload: { dbName, useOpfs } } -> { id, ok, persisted, opfsError? }
|
|
87
147
|
* { id, type: 'exec', payload: { sql, bind } } -> { id, ok, rows }
|
|
88
148
|
* { id, type: 'run', payload: { sql, bind } } -> { id, ok }
|
|
89
149
|
* { id, type: 'batch', payload: [{ sql, bind }] } (atomic BEGIN/COMMIT)
|
|
@@ -97,18 +157,16 @@ async function executeSelect(plan, params, db) {
|
|
|
97
157
|
*/
|
|
98
158
|
let db = null;
|
|
99
159
|
async function open(dbName, useOpfs, systemTables = []) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
if (useOpfs && sqlite3.installOpfsSAHPoolVfs) try {
|
|
103
|
-
db = new (await (sqlite3.installOpfsSAHPoolVfs({ name: `sp00ky-${dbName}` }))).OpfsSAHPoolDb(`/${dbName}.sqlite3`);
|
|
104
|
-
persisted = true;
|
|
105
|
-
} catch {}
|
|
106
|
-
if (!db) db = new sqlite3.oo1.DB(":memory:", "c");
|
|
160
|
+
const { db: handle, persisted, opfsError } = await openDb(await sqlite3InitModule(), dbName, useOpfs);
|
|
161
|
+
db = handle;
|
|
107
162
|
for (const t of systemTables) db.exec({ sql: `CREATE TABLE IF NOT EXISTS "${t}" (id TEXT PRIMARY KEY, data TEXT NOT NULL)` });
|
|
108
163
|
try {
|
|
109
164
|
db.exec({ sql: "PRAGMA busy_timeout = 5000; PRAGMA cache_size = -32000;" });
|
|
110
165
|
} catch {}
|
|
111
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
persisted,
|
|
168
|
+
opfsError
|
|
169
|
+
};
|
|
112
170
|
}
|
|
113
171
|
function exec(sql, bind) {
|
|
114
172
|
if (!db) throw new Error("sqlite: DB not open");
|
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,24 @@ 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>;
|
|
321
|
+
/**
|
|
322
|
+
* Durability of this engine's local store. OPTIONAL: engines that don't
|
|
323
|
+
* report it (SurrealDB, custom engines) are treated as `'unknown'` by the
|
|
324
|
+
* client facade, so adding this needs no change on their side.
|
|
325
|
+
*/
|
|
326
|
+
readonly storageHealth?: StorageHealth;
|
|
327
|
+
/** Fires immediately with the current snapshot, then on every change.
|
|
328
|
+
* Returns an unsubscribe function. */
|
|
329
|
+
subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
|
|
291
330
|
}
|
|
292
331
|
/** Selected local cache backend. Mirrors the `persistenceClient` config pattern. */
|
|
293
332
|
type LocalEngineChoice = 'surrealdb' | 'sqlite' | LocalStore;
|
|
@@ -530,6 +569,31 @@ interface SyncHealth {
|
|
|
530
569
|
*/
|
|
531
570
|
everConnected: boolean;
|
|
532
571
|
}
|
|
572
|
+
type StorageHealthStatus = 'unknown' | 'persistent' | 'memory';
|
|
573
|
+
/**
|
|
574
|
+
* Durability of the LOCAL cache, delivered to `subscribeToStorageHealth`
|
|
575
|
+
* subscribers. Separate from {@link SyncHealth}: that one is about reaching the
|
|
576
|
+
* server, this one is about whether the local store survives a reload.
|
|
577
|
+
*
|
|
578
|
+
* Under `localEngine: 'sqlite'` the durable store is the OPFS SAHPool VFS,
|
|
579
|
+
* which only one client per bucket can hold open. When it can't be opened (a
|
|
580
|
+
* second tab of the app already has it, an insecure context, a full pool) the
|
|
581
|
+
* engine keeps working against an in-memory DB, which holds the whole dataset
|
|
582
|
+
* in RAM and loses local writes on reload. `fallback` marks exactly that case,
|
|
583
|
+
* so a UI can warn about it.
|
|
584
|
+
*/
|
|
585
|
+
interface StorageHealth {
|
|
586
|
+
/** `'unknown'` until the local cache has opened, or for engines that don't report. */
|
|
587
|
+
status: StorageHealthStatus;
|
|
588
|
+
/**
|
|
589
|
+
* `true` only when durable storage was REQUESTED and could not be opened.
|
|
590
|
+
* Stays `false` for a configured-in-memory store (`store: 'memory'`), which
|
|
591
|
+
* is a choice rather than a failure, so a UI can key off this alone.
|
|
592
|
+
*/
|
|
593
|
+
fallback: boolean;
|
|
594
|
+
/** Reason durable storage failed (only set while `fallback` is `true`). */
|
|
595
|
+
error?: string;
|
|
596
|
+
}
|
|
533
597
|
type QueryHash = string;
|
|
534
598
|
type RecordVersionArray = Array<[string, number]>;
|
|
535
599
|
/**
|
|
@@ -752,4 +816,4 @@ interface DebounceOptions {
|
|
|
752
816
|
delay?: number;
|
|
753
817
|
}
|
|
754
818
|
//#endregion
|
|
755
|
-
export {
|
|
819
|
+
export { StorageHealthStatus as A, DatabaseEventSystem as B, RecordVersionDiff as C, Sp00kyQueryResult as D, Sp00kyConfig as E, TimingPhase as F, EventSystem as G, Logger$1 as H, UpdateOptions as I, UpEvent as L, SyncHealth as M, SyncHealthConfig as N, Sp00kyQueryResultPromise as O, SyncHealthStatus as P, LocalStore as R, RecordVersionArray as S, RunOptions as T, SyncEventSystem as U, DatabaseEventTypes as V, EventDefinition as W, QueryStatus as _, MutationCallback as a, QueryTimings as b, PersistenceClient as c, PreloadOptions as d, PreloadRefresh as f, QueryState as g, QueryHash as h, MATERIALIZATION_SAMPLE_WINDOW as i, StoreType as j, StorageHealth as k, PhaseStat as l, QueryConfigRecord as m, EventSubscriptionOptions as n, MutationEvent as o, QueryConfig as p, Level$1 as r, MutationEventType as s, DebounceOptions as t, PinoTransmit as u, QueryStatusCallback as v, RegistrationTimings as w, QueryUpdateCallback as x, QueryTimeToLive as y, SealedQuery as z };
|
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",
|