@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 CHANGED
@@ -1,4 +1,4 @@
1
- import { A as SyncHealth, B as Logger$1, C as RecordVersionDiff, D as Sp00kyQueryResult, E as Sp00kyConfig, F as UpEvent, H as EventDefinition, I as LocalStore, L as SealedQuery, M as SyncHealthStatus, N as TimingPhase, O as Sp00kyQueryResultPromise, P as UpdateOptions, R as DatabaseEventSystem, S as RecordVersionArray, T as RunOptions, U as EventSystem, V as SyncEventSystem, _ 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 SyncHealthConfig, k as StoreType, 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 DatabaseEventTypes } from "./types.js";
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.logger.info({
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 (no OPFS)");
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.152";
5055
- const WASM_VERSION = "0.0.1-canary.152";
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)) result[key] = this.serializeForDevTools(data[key], seen);
5496
+ for (const key in data) if (Object.prototype.hasOwnProperty.call(data, key)) {
5497
+ if (data[key] === void 0) continue;
5498
+ result[key] = this.serializeForDevTools(data[key], seen);
5499
+ }
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);
@@ -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. Falls back
83
- * to an in-memory DB when OPFS is unavailable.
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 sqlite3 = await sqlite3InitModule();
101
- let persisted = false;
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 { persisted };
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 { SyncHealth as A, Logger$1 as B, RecordVersionDiff as C, Sp00kyQueryResult as D, Sp00kyConfig as E, UpEvent as F, EventDefinition as H, LocalStore as I, SealedQuery as L, SyncHealthStatus as M, TimingPhase as N, Sp00kyQueryResultPromise as O, UpdateOptions as P, DatabaseEventSystem as R, RecordVersionArray as S, RunOptions as T, EventSystem as U, SyncEventSystem as V, 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, SyncHealthConfig as j, StoreType 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, DatabaseEventTypes as z };
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.152",
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.152",
64
- "@spooky-sync/ssp-wasm": "0.0.1-canary.152",
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",