@spooky-sync/core 0.0.1-canary.20 → 0.0.1-canary.201

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.
Files changed (148) hide show
  1. package/AGENTS.md +57 -0
  2. package/dist/index.d.ts +2184 -54
  3. package/dist/index.js +11515 -2399
  4. package/dist/otel/index.d.ts +2 -2
  5. package/dist/otel/index.js +6 -6
  6. package/dist/sqlite-open.js +276 -0
  7. package/dist/sqlite-worker.d.ts +1 -0
  8. package/dist/sqlite-worker.js +421 -0
  9. package/dist/tabs-broker-worker.d.ts +8 -0
  10. package/dist/tabs-broker-worker.js +434 -0
  11. package/dist/types.d.ts +688 -11
  12. package/package.json +11 -7
  13. package/scripts/check-broker-bundle.mjs +33 -0
  14. package/skills/{spooky-core → sp00ky-core}/SKILL.md +12 -12
  15. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  16. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  17. package/src/bucket-blurhash.test.ts +148 -0
  18. package/src/build-globals.d.ts +12 -0
  19. package/src/events/events.test.ts +2 -1
  20. package/src/events/index.ts +3 -0
  21. package/src/index.ts +35 -2
  22. package/src/modules/app-release/index.test.ts +125 -0
  23. package/src/modules/app-release/index.ts +201 -0
  24. package/src/modules/auth/events/index.ts +2 -1
  25. package/src/modules/auth/index.ts +59 -20
  26. package/src/modules/cache/index.ts +112 -32
  27. package/src/modules/cache/types.ts +2 -2
  28. package/src/modules/crdt/crdt-field.ts +294 -0
  29. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  30. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  31. package/src/modules/crdt/index.ts +463 -0
  32. package/src/modules/crdt/loro-loader.ts +25 -0
  33. package/src/modules/data/data.hydration.test.ts +142 -0
  34. package/src/modules/data/data.membership.test.ts +462 -0
  35. package/src/modules/data/data.rebind.test.ts +147 -0
  36. package/src/modules/data/data.run.test.ts +113 -0
  37. package/src/modules/data/data.settled-writes.test.ts +206 -0
  38. package/src/modules/data/data.status.test.ts +249 -0
  39. package/src/modules/data/id-set-plan.test.ts +122 -0
  40. package/src/modules/data/index.ts +1580 -130
  41. package/src/modules/data/mutation-id.test.ts +25 -0
  42. package/src/modules/data/mutation-id.ts +35 -0
  43. package/src/modules/data/window-query.test.ts +52 -0
  44. package/src/modules/data/window-query.ts +194 -0
  45. package/src/modules/devtools/flags.ts +349 -0
  46. package/src/modules/devtools/index.ts +386 -37
  47. package/src/modules/devtools/notify-throttle.test.ts +149 -0
  48. package/src/modules/devtools/storage-info.test.ts +79 -0
  49. package/src/modules/devtools/storage-info.ts +168 -0
  50. package/src/modules/devtools/versions.test.ts +74 -0
  51. package/src/modules/devtools/versions.ts +110 -0
  52. package/src/modules/feature-flag/index.test.ts +251 -0
  53. package/src/modules/feature-flag/index.ts +308 -0
  54. package/src/modules/ref-tables.test.ts +91 -0
  55. package/src/modules/ref-tables.ts +88 -0
  56. package/src/modules/sync/engine.ts +101 -37
  57. package/src/modules/sync/events/index.ts +9 -2
  58. package/src/modules/sync/queue/queue-down.test.ts +107 -0
  59. package/src/modules/sync/queue/queue-down.ts +35 -6
  60. package/src/modules/sync/queue/queue-up.forwarded.test.ts +164 -0
  61. package/src/modules/sync/queue/queue-up.ts +241 -57
  62. package/src/modules/sync/scheduler.pause.test.ts +109 -0
  63. package/src/modules/sync/scheduler.retry.test.ts +156 -0
  64. package/src/modules/sync/scheduler.ts +158 -11
  65. package/src/modules/sync/sync.cleanup.test.ts +116 -0
  66. package/src/modules/sync/sync.health.test.ts +149 -0
  67. package/src/modules/sync/sync.heartbeat.test.ts +80 -0
  68. package/src/modules/sync/sync.live-removal.test.ts +134 -0
  69. package/src/modules/sync/sync.reconnect.test.ts +145 -0
  70. package/src/modules/sync/sync.subquery.test.ts +82 -0
  71. package/src/modules/sync/sync.ts +1558 -99
  72. package/src/modules/sync/utils.test.ts +269 -2
  73. package/src/modules/sync/utils.ts +201 -17
  74. package/src/otel/index.ts +13 -10
  75. package/src/services/blobs/blob-cache.test.ts +359 -0
  76. package/src/services/blobs/blob-cache.ts +603 -0
  77. package/src/services/blobs/blob-manifest.ts +227 -0
  78. package/src/services/blobs/blob-store.test.ts +77 -0
  79. package/src/services/blobs/blob-store.ts +359 -0
  80. package/src/services/blobs/blob.fixture.ts +90 -0
  81. package/src/services/blobs/index.ts +70 -0
  82. package/src/services/database/cache-engine.ts +160 -0
  83. package/src/services/database/connection-supervisor.test.ts +289 -0
  84. package/src/services/database/connection-supervisor.ts +415 -0
  85. package/src/services/database/database.query-timeout.test.ts +83 -0
  86. package/src/services/database/database.ts +32 -12
  87. package/src/services/database/engine-factory.ts +33 -0
  88. package/src/services/database/events/index.ts +2 -1
  89. package/src/services/database/index.ts +7 -0
  90. package/src/services/database/local-migrator.ts +30 -27
  91. package/src/services/database/local.test.ts +64 -0
  92. package/src/services/database/local.ts +478 -67
  93. package/src/services/database/plan-render.test.ts +159 -0
  94. package/src/services/database/plan-render.ts +108 -0
  95. package/src/services/database/relation-resolver.test.ts +413 -0
  96. package/src/services/database/relation-resolver.ts +0 -0
  97. package/src/services/database/remote.ts +110 -14
  98. package/src/services/database/sqlite-cache-engine.test.ts +558 -0
  99. package/src/services/database/sqlite-cache-engine.ts +1257 -0
  100. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  101. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  102. package/src/services/database/sqlite-open.test.ts +150 -0
  103. package/src/services/database/sqlite-open.ts +164 -0
  104. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  105. package/src/services/database/sqlite-plan-sql.ts +106 -0
  106. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  107. package/src/services/database/sqlite-select.test.ts +246 -0
  108. package/src/services/database/sqlite-select.ts +121 -0
  109. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  110. package/src/services/database/sqlite-transport.ts +221 -0
  111. package/src/services/database/sqlite-worker.ts +437 -0
  112. package/src/services/database/surql-translate.ts +416 -0
  113. package/src/services/database/surreal-cache-engine.ts +141 -0
  114. package/src/services/logger/index.ts +3 -2
  115. package/src/services/persistence/localstorage.ts +2 -2
  116. package/src/services/persistence/resilient.ts +11 -4
  117. package/src/services/persistence/surrealdb.ts +10 -10
  118. package/src/services/stream-processor/index.ts +444 -52
  119. package/src/services/stream-processor/permissions.test.ts +47 -0
  120. package/src/services/stream-processor/permissions.ts +53 -0
  121. package/src/services/stream-processor/stream-processor.batch.test.ts +136 -0
  122. package/src/services/stream-processor/stream-processor.reset.test.ts +216 -0
  123. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  124. package/src/services/stream-processor/wasm-types.ts +23 -2
  125. package/src/services/tabs/broker-client.ts +283 -0
  126. package/src/services/tabs/broker.test.ts +278 -0
  127. package/src/services/tabs/coordinator.test.ts +244 -0
  128. package/src/services/tabs/coordinator.ts +576 -0
  129. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  130. package/src/services/tabs/leader-locks.ts +75 -0
  131. package/src/services/tabs/protocol.ts +242 -0
  132. package/src/services/tabs/support.ts +36 -0
  133. package/src/services/tabs/tabs-broker-worker.ts +586 -0
  134. package/src/sp00ky.auth-order.test.ts +92 -0
  135. package/src/sp00ky.init-query.test.ts +183 -0
  136. package/src/sp00ky.ts +1543 -0
  137. package/src/types.ts +496 -13
  138. package/src/utils/blurhash.ts +90 -0
  139. package/src/utils/error-classification.test.ts +44 -0
  140. package/src/utils/error-classification.ts +7 -0
  141. package/src/utils/index.ts +73 -13
  142. package/src/utils/parser.ts +3 -2
  143. package/src/utils/semver.test.ts +32 -0
  144. package/src/utils/semver.ts +30 -0
  145. package/src/utils/surql.ts +30 -18
  146. package/src/utils/withRetry.test.ts +1 -1
  147. package/tsdown.config.ts +86 -1
  148. package/src/spooky.ts +0 -395
@@ -1,8 +1,8 @@
1
- import { LocalDatabaseService, RemoteDatabaseService } from '../../services/database/index';
2
- import { Logger } from '../../services/logger/index';
3
- import { SchemaStructure } from '@spooky-sync/query-builder';
1
+ import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
2
+ import type { Logger } from '../../services/logger/index';
3
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
4
4
  import { RecordId } from 'surrealdb';
5
- import { StreamUpdate, StreamUpdateReceiver } from '../../services/stream-processor/index';
5
+ import type { StreamUpdate, StreamUpdateReceiver } from '../../services/stream-processor/index';
6
6
  import { encodeRecordId } from '../../utils/index';
7
7
 
8
8
  // DevTools interfaces (matching extension expectations)
@@ -13,31 +13,168 @@ export interface DevToolsEvent {
13
13
  payload: any;
14
14
  }
15
15
 
16
- import { DataModule } from '../data/index';
17
- import { AuthService } from '../auth/index';
16
+ import type { DataModule } from '../data/index';
17
+ import type { AuthService } from '../auth/index';
18
18
  import { AuthEventTypes } from '../auth/events/index';
19
+ import {
20
+ type BackendInfo,
21
+ emptyBackendInfo,
22
+ parseBackendInfo,
23
+ UNAVAILABLE,
24
+ } from './versions';
25
+ import { walkOpfs, type BlobCacheInfo, type SharedTabsInfo, type StorageInfo } from './storage-info';
26
+ import { FlagsAdminService, type LocalOverrideStore } from './flags';
27
+
28
+ // Real bundled frontend versions, injected at build time by tsdown's
29
+ // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
30
+ // from throwing a ReferenceError when a downstream app bundles core from source
31
+ // (where the plugin never runs); in that case they fall back to 'unknown' and
32
+ // DevTools simply reports an unknown frontend version instead of crashing.
33
+ const CORE_VERSION =
34
+ typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown';
35
+ const WASM_VERSION =
36
+ typeof __SP00KY_WASM_VERSION__ !== 'undefined' ? __SP00KY_WASM_VERSION__ : 'unknown';
37
+ const SURREAL_VERSION =
38
+ typeof __SP00KY_SURREAL_VERSION__ !== 'undefined' ? __SP00KY_SURREAL_VERSION__ : 'unknown';
19
39
 
20
40
  export class DevToolsService implements StreamUpdateReceiver {
21
41
  private eventsHistory: DevToolsEvent[] = [];
22
42
  private eventIdCounter = 0;
23
- private version = '1.0.0';
43
+ // Real bundled frontend version (injected at build time via tsdown `define`).
44
+ private version = CORE_VERSION;
45
+ // Backend stack info (versions + per-entity status), read via the
46
+ // `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
47
+ private backendInfo: BackendInfo = emptyBackendInfo();
48
+ // Dormant until a devtools consumer (extension panel or MCP) handshakes via
49
+ // `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()`/`addEvent()` do no
50
+ // work, so prod pays zero serialization/postMessage cost for an unwatched panel.
51
+ // `window.__00__.getState()` stays live regardless, so the panel's first paint
52
+ // (the on-demand GET_STATE pull) still works before the push channel turns on.
53
+ private enabled = false;
54
+
55
+ // A state push serializes EVERY active query's full record set (see
56
+ // `getActiveQueries`) and postMessage clones it again, so its cost scales with
57
+ // the whole client dataset — and it is triggered per event, including one per
58
+ // local DB query (`DATABASE_LOCAL_QUERY` → `logEvent`). Unthrottled, a single
59
+ // page load's few hundred local queries turn a handful of MB of rows into GBs
60
+ // of short-lived large-object garbage and OOM the renderer (V8
61
+ // "young object promotion failed"). Coalesce instead: push immediately when
62
+ // idle, then at most once per window, always serializing the LATEST state.
63
+ private static readonly NOTIFY_MIN_INTERVAL_MS = 250;
64
+ private notifyTimer: ReturnType<typeof setTimeout> | null = null;
65
+ private lastNotifyAt = 0;
66
+
67
+ /** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
68
+ * feature was REQUESTED (so an inactive/degraded tab still reports why). */
69
+ private tabsInfoProvider: (() => SharedTabsInfo | null) | null = null;
70
+
71
+ setTabsInfoProvider(provider: () => SharedTabsInfo | null): void {
72
+ this.tabsInfoProvider = provider;
73
+ }
74
+
75
+ /** Blob cache counters for the panel, wired by Sp00kyClient. */
76
+ private blobInfoProvider: (() => BlobCacheInfo) | null = null;
77
+
78
+ setBlobInfoProvider(provider: () => BlobCacheInfo): void {
79
+ this.blobInfoProvider = provider;
80
+ }
81
+
82
+ // Full local table list (incl. internal `_00_*`), enumerated from the local DB
83
+ // via our own reliable `service.query` — the DevTools panel's page-eval query
84
+ // bridge is unreliable under load, so the panel relies on this instead.
85
+ private localTables: string[] = [];
86
+ private localTablesFetching = false;
87
+ private localTablesAt = 0;
88
+
89
+ // Feature flag admin, backing the panel's Access tab. The local-override
90
+ // store is injected later (`setFeatureFlagOverrides`) because the
91
+ // FeatureFlagModule is built after this service.
92
+ private featureOverrides: LocalOverrideStore | null = null;
93
+ private readonly flagsAdmin: FlagsAdminService;
24
94
 
25
95
  constructor(
26
- private databaseService: LocalDatabaseService,
96
+ private databaseService: LocalStore,
27
97
  private remoteDatabaseService: RemoteDatabaseService,
28
98
  private logger: Logger,
29
99
  private schema: SchemaStructure,
30
100
  private authService: AuthService<SchemaStructure>,
31
101
  private dataManager?: DataModule<SchemaStructure>
32
102
  ) {
103
+ this.flagsAdmin = new FlagsAdminService({
104
+ remote: this.remoteDatabaseService,
105
+ local: this.databaseService,
106
+ logger: this.logger,
107
+ currentUserId: () => {
108
+ const id = this.authService.currentUser?.id;
109
+ if (!id) return null;
110
+ return id instanceof RecordId ? encodeRecordId(id) : String(id);
111
+ },
112
+ overrides: () => this.featureOverrides,
113
+ });
114
+
33
115
  this.exposeToWindow();
34
116
 
35
- // Subscribe to auth events
117
+ // Stay dormant until a devtools consumer announces itself. The extension's
118
+ // page-script posts this once it detects `window.__00__`; the panel can also
119
+ // disconnect to return us to dormant. Until then we skip all serialization.
120
+ if (typeof window !== 'undefined') {
121
+ window.addEventListener('message', (e) => {
122
+ if (e.source !== window) return;
123
+ const type = (e.data as { type?: string } | undefined)?.type;
124
+ if (type === 'SP00KY_DEVTOOLS_CONNECT') {
125
+ this.enabled = true;
126
+ this.notifyDevTools();
127
+ } else if (type === 'SP00KY_DEVTOOLS_DISCONNECT') {
128
+ this.enabled = false;
129
+ }
130
+ });
131
+ }
132
+
133
+ // Subscribe to auth events. The initial fire-and-forget version fetch (below)
134
+ // races the remote connection; on the free plan the remote DB (SurrealDB
135
+ // Cloud) has no guest access, so `fn::spooky::info()` is only callable once
136
+ // signed in. Re-fetch when auth resolves — until the versions actually land —
137
+ // instead of leaving them 'unavailable' forever.
36
138
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
37
- this.notifyDevTools();
139
+ if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) {
140
+ void this.refreshBackendVersions();
141
+ } else {
142
+ this.notifyDevTools();
143
+ }
38
144
  });
39
145
 
40
- this.logger.debug({ Category: 'spooky-client::DevToolsService::init' }, 'Service initialized');
146
+ // Push state when the local store reports its durability (the open happens
147
+ // during connect, typically before a panel attaches, so this mostly matters
148
+ // for a later bucket switch that loses OPFS).
149
+ this.databaseService.subscribeToStorageHealth?.(() => this.notifyDevTools());
150
+
151
+ // Fire-and-forget backend version discovery; re-push state when it lands.
152
+ void this.refreshBackendVersions();
153
+
154
+ this.logger.debug({ Category: 'sp00ky-client::DevToolsService::init' }, 'Service initialized');
155
+ }
156
+
157
+ /**
158
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
159
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
160
+ * Never throws: on failure the info stays empty/'unavailable'.
161
+ */
162
+ private async refreshBackendVersions(): Promise<void> {
163
+ try {
164
+ // `RETURN fn::spooky::info()` → one statement result: the /info entity array.
165
+ const result = await this.remoteDatabaseService.query<unknown[]>(
166
+ 'RETURN fn::spooky::info()'
167
+ );
168
+ const first = Array.isArray(result) ? result[0] : result;
169
+ this.backendInfo = parseBackendInfo(first);
170
+ } catch (err) {
171
+ this.logger.debug(
172
+ { err, Category: 'sp00ky-client::DevToolsService::versions' },
173
+ 'fn::spooky::info() unavailable; backend versions stay unavailable'
174
+ );
175
+ this.backendInfo = emptyBackendInfo();
176
+ }
177
+ this.notifyDevTools();
41
178
  }
42
179
 
43
180
  // Get active queries directly from DataManager (single source of truth)
@@ -48,21 +185,33 @@ export class DevToolsService implements StreamUpdateReceiver {
48
185
  const queries = this.dataManager.getActiveQueries();
49
186
  queries.forEach((q) => {
50
187
  const queryHash = this.hashString(encodeRecordId(q.config.id));
188
+ const createdAt =
189
+ q.config.lastActiveAt instanceof Date
190
+ ? q.config.lastActiveAt.getTime()
191
+ : new Date(q.config.lastActiveAt || Date.now()).getTime();
51
192
  result.set(queryHash, {
52
193
  queryHash,
53
194
  status: 'active',
54
- createdAt:
55
- q.config.lastActiveAt instanceof Date
56
- ? q.config.lastActiveAt.getTime()
57
- : new Date(q.config.lastActiveAt || Date.now()).getTime(),
58
- lastUpdate: Date.now(),
195
+ // Runtime fetch status, distinct from the `status: 'active'`
196
+ // registration flag above. `fetchStatus` is 'idle' | 'fetching'.
197
+ fetchStatus: q.status,
198
+ isFetching: q.status === 'fetching',
199
+ createdAt,
200
+ // Real last-update time; before the first update it equals createdAt.
201
+ // (Previously Date.now(), which reset the column on every state push.)
202
+ lastUpdate: q.lastUpdatedAt ?? createdAt,
59
203
  updateCount: q.updateCount,
204
+ ttl: q.config.ttl,
60
205
  query: q.config.surql,
61
206
  variables: q.config.params || {},
62
207
  dataSize: q.records?.length || 0,
63
208
  data: q.records,
64
209
  localArray: q.config.localArray,
65
210
  remoteArray: q.config.remoteArray,
211
+ // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
212
+ // remote record fetch, frontend reconcile, registration). Flows to both
213
+ // the DevTools panel and the MCP (which returns activeQueries verbatim).
214
+ timings: this.dataManager.phaseTimings(q),
66
215
  });
67
216
  });
68
217
  return result;
@@ -70,7 +219,7 @@ export class DevToolsService implements StreamUpdateReceiver {
70
219
 
71
220
  public onQueryInitialized(payload: any) {
72
221
  this.logger.debug(
73
- { payload, Category: 'spooky-client::DevToolsService::onQueryInitialized' },
222
+ { payload, Category: 'sp00ky-client::DevToolsService::onQueryInitialized' },
74
223
  'QueryInitialized'
75
224
  );
76
225
  const queryHash = this.hashString(payload.queryId.toString());
@@ -87,7 +236,7 @@ export class DevToolsService implements StreamUpdateReceiver {
87
236
  this.logger.debug(
88
237
  {
89
238
  id: payload.queryId?.toString(),
90
- Category: 'spooky-client::DevToolsService::onQueryUpdated',
239
+ Category: 'sp00ky-client::DevToolsService::onQueryUpdated',
91
240
  },
92
241
  'QueryUpdated'
93
242
  );
@@ -102,7 +251,7 @@ export class DevToolsService implements StreamUpdateReceiver {
102
251
 
103
252
  public onStreamUpdate(update: StreamUpdate) {
104
253
  this.logger.debug(
105
- { update, Category: 'spooky-client::DevToolsService::onStreamUpdate' },
254
+ { update, Category: 'sp00ky-client::DevToolsService::onStreamUpdate' },
106
255
  'StreamUpdate'
107
256
  );
108
257
  this.addEvent('STREAM_UPDATE', {
@@ -142,6 +291,8 @@ export class DevToolsService implements StreamUpdateReceiver {
142
291
  }
143
292
 
144
293
  private addEvent(eventType: string, payload: any) {
294
+ // No consumer attached → skip recording (and the recursive serialize it does).
295
+ if (!this.enabled) return;
145
296
  this.eventsHistory.push({
146
297
  id: this.eventIdCounter++,
147
298
  timestamp: Date.now(),
@@ -151,7 +302,50 @@ export class DevToolsService implements StreamUpdateReceiver {
151
302
  if (this.eventsHistory.length > 100) this.eventsHistory.shift();
152
303
  }
153
304
 
305
+ /** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
306
+ private unwrapInfo(res: any): any {
307
+ if (!Array.isArray(res) || !res[0]) return null;
308
+ const first = res[0];
309
+ if (first && typeof first === 'object' && 'result' in first) return first.result;
310
+ if (Array.isArray(first)) return first[0];
311
+ return first;
312
+ }
313
+
314
+ /**
315
+ * Refresh the cached full local-table list from `INFO FOR DB`. Fire-and-forget
316
+ * and throttled — called from getState() so the panel gets every table
317
+ * (including internal `_00_*`) without running its own (flaky) queries.
318
+ */
319
+ private refreshLocalTables(): void {
320
+ if (this.localTablesFetching) return;
321
+ const now = Date.now();
322
+ if (now - this.localTablesAt < 3000) return;
323
+ this.localTablesFetching = true;
324
+ void this.databaseService
325
+ .query<any>('INFO FOR DB')
326
+ .then((res) => {
327
+ const info = this.unwrapInfo(res);
328
+ this.localTablesAt = Date.now();
329
+ if (info && info.tables) {
330
+ const names = Object.keys(info.tables);
331
+ const changed =
332
+ names.length !== this.localTables.length ||
333
+ names.some((n, i) => n !== this.localTables[i]);
334
+ this.localTables = names;
335
+ if (changed) this.notifyDevTools();
336
+ }
337
+ })
338
+ .catch(() => {
339
+ // Ignore — fall back to the declared app schema below.
340
+ })
341
+ .finally(() => {
342
+ this.localTablesFetching = false;
343
+ });
344
+ }
345
+
154
346
  private getState() {
347
+ // Keep the full local-table list fresh (throttled, non-blocking).
348
+ this.refreshLocalTables();
155
349
  return this.serializeForDevTools({
156
350
  eventsHistory: [...this.eventsHistory],
157
351
  activeQueries: Object.fromEntries(this.getActiveQueries()),
@@ -160,24 +354,147 @@ export class DevToolsService implements StreamUpdateReceiver {
160
354
  userId: this.authService.currentUser?.id,
161
355
  },
162
356
  version: this.version,
357
+ versions: {
358
+ frontend: {
359
+ core: CORE_VERSION,
360
+ wasm: WASM_VERSION,
361
+ surrealdb: SURREAL_VERSION,
362
+ },
363
+ backend: this.backendInfo.versions,
364
+ entities: this.backendInfo.entities,
365
+ },
163
366
  database: {
164
- tables: this.schema.tables.map((t) => t.name),
367
+ // Prefer the live local-table list (includes internal `_00_*`); fall
368
+ // back to the declared app schema until the first enumeration lands.
369
+ tables: this.localTables.length
370
+ ? this.localTables
371
+ : this.schema.tables.map((t) => t.name),
165
372
  tableData: {},
373
+ // Which backend answers "Local". The Database explorer labels its source
374
+ // picker with it and explains translation failures against `sqlite`,
375
+ // whose SurrealQL vocabulary is a bounded subset.
376
+ engine: this.databaseService.engineKind ?? 'custom',
377
+ // Durability of the local store. `fallback: true` means persistence was
378
+ // requested but the dataset is actually sitting in RAM.
379
+ storage: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
380
+ // Shared-tabs role state (null when the feature is off / fell back).
381
+ tabs: this.tabsInfoProvider?.() ?? null,
166
382
  },
167
383
  });
168
384
  }
169
385
 
170
- private notifyDevTools() {
171
- if (typeof window !== 'undefined') {
172
- window.postMessage(
173
- {
174
- type: 'SPOOKY_STATE_CHANGED',
175
- source: 'spooky-devtools-page',
176
- state: this.getState(),
177
- },
178
- '*'
386
+ /**
387
+ * Full storage diagnostics for the DevTools Storage tab. Every section is
388
+ * gathered independently and failures land in that section's `error` field,
389
+ * so one broken source (a mid-switch worker, a browser without OPFS) never
390
+ * blanks the whole panel.
391
+ */
392
+ public async getStorageInfo(opts?: { tableCounts?: boolean }): Promise<StorageInfo> {
393
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
394
+
395
+ const info: StorageInfo = {
396
+ at: Date.now(),
397
+ engine: {
398
+ kind: this.databaseService.engineKind ?? 'custom',
399
+ store: this.databaseService.getConfig()?.store ?? 'memory',
400
+ bucketId: this.databaseService.currentBucketId,
401
+ },
402
+ health: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
403
+ tabs: this.tabsInfoProvider?.() ?? null,
404
+ browser: {},
405
+ opfs: { supported: false, entries: [], totalBytes: 0, truncated: false },
406
+ };
407
+
408
+ try {
409
+ if (nav?.storage?.estimate) {
410
+ const est = await nav.storage.estimate();
411
+ info.browser.usage = est.usage;
412
+ info.browser.quota = est.quota;
413
+ // Chrome-only per-storage-system breakdown; absent elsewhere.
414
+ const details = (est as any).usageDetails;
415
+ if (details && typeof details === 'object') info.browser.usageDetails = details;
416
+ }
417
+ if (nav?.storage?.persisted) {
418
+ info.browser.persisted = await nav.storage.persisted();
419
+ }
420
+ } catch (e) {
421
+ info.browser.error = e instanceof Error ? e.message : String(e);
422
+ }
423
+
424
+ info.opfs = await walkOpfs();
425
+
426
+ try {
427
+ info.blobs = this.blobInfoProvider?.();
428
+ } catch (e) {
429
+ this.logger.warn(
430
+ { err: e, Category: 'sp00ky-client::DevToolsService::getStorageInfo' },
431
+ 'Blob cache diagnostics failed'
432
+ );
433
+ }
434
+
435
+ const stats = (globalThis as any).__sqliteStats;
436
+ if (stats && typeof stats === 'object') {
437
+ info.sqliteStats = { ...stats, byType: { ...(stats.byType ?? {}) } };
438
+ }
439
+
440
+ try {
441
+ info.engineDiagnostics = await this.databaseService.getStorageDiagnostics?.(opts);
442
+ } catch (e) {
443
+ this.logger.warn(
444
+ { err: e, Category: 'sp00ky-client::DevToolsService::getStorageInfo' },
445
+ 'Engine storage diagnostics failed'
179
446
  );
180
447
  }
448
+
449
+ return this.serializeForDevTools(info);
450
+ }
451
+
452
+ /** Ask the browser to exempt this origin's storage from eviction. */
453
+ public async requestPersistentStorage(): Promise<{ granted: boolean }> {
454
+ try {
455
+ const granted = (await navigator.storage?.persist?.()) ?? false;
456
+ return { granted };
457
+ } catch {
458
+ return { granted: false };
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Request a state push. Coalesced (see {@link NOTIFY_MIN_INTERVAL_MS}): the
464
+ * first call after an idle period pushes straight away so the panel stays
465
+ * responsive, and any calls during the window collapse into ONE trailing push
466
+ * that serializes the state as of the flush, not as of the request. Callers
467
+ * stay fire-and-forget.
468
+ */
469
+ private notifyDevTools() {
470
+ // No consumer attached → no getState() serialization, no postMessage broadcast.
471
+ if (!this.enabled) return;
472
+ if (typeof window === 'undefined') return;
473
+ // A trailing push is already queued; it will carry this change too.
474
+ if (this.notifyTimer !== null) return;
475
+
476
+ const waited = Date.now() - this.lastNotifyAt;
477
+ if (waited >= DevToolsService.NOTIFY_MIN_INTERVAL_MS) {
478
+ this.flushNotify();
479
+ return;
480
+ }
481
+ this.notifyTimer = setTimeout(() => {
482
+ this.notifyTimer = null;
483
+ // Still gated on `enabled`: the panel may have disconnected while queued.
484
+ if (this.enabled) this.flushNotify();
485
+ }, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
486
+ }
487
+
488
+ private flushNotify() {
489
+ this.lastNotifyAt = Date.now();
490
+ window.postMessage(
491
+ {
492
+ type: 'SP00KY_STATE_CHANGED',
493
+ source: 'sp00ky-devtools-page',
494
+ state: this.getState(),
495
+ },
496
+ '*'
497
+ );
181
498
  }
182
499
 
183
500
  private serializeForDevTools(data: any, seen = new WeakSet<object>()): any {
@@ -218,6 +535,10 @@ export class DevToolsService implements StreamUpdateReceiver {
218
535
  const result: Record<string, any> = {};
219
536
  for (const key in data) {
220
537
  if (Object.prototype.hasOwnProperty.call(data, key)) {
538
+ // Skip absent optional fields: recursing them would emit the STRING
539
+ // 'undefined' (the top-level mapping below), which panels then have
540
+ // to filter back out (see 3d84fe8a).
541
+ if (data[key] === undefined) continue;
221
542
  result[key] = this.serializeForDevTools(data[key], seen);
222
543
  }
223
544
  }
@@ -227,15 +548,40 @@ export class DevToolsService implements StreamUpdateReceiver {
227
548
  return data;
228
549
  }
229
550
 
551
+ /**
552
+ * Hand the FeatureFlagModule to the Access tab so it can read and write local
553
+ * overrides. Called from `Sp00kyClient` once both are constructed; until then
554
+ * the override methods are no-ops that report an empty map.
555
+ */
556
+ public setFeatureFlagOverrides(store: LocalOverrideStore): void {
557
+ this.featureOverrides = store;
558
+ }
559
+
230
560
  private exposeToWindow() {
231
561
  if (typeof window !== 'undefined') {
232
- (window as any).__SPOOKY__ = {
562
+ (window as any).__00__ = {
233
563
  version: this.version,
234
564
  getState: () => this.getState(),
565
+ // ---- Feature flags (Access tab) --------------------------------
566
+ // Remote reads/writes are admin-gated by SurrealDB, not here: a
567
+ // non-admin gets an empty flag list, and the `fn::feature::*` calls
568
+ // are denied outright. The override methods are purely local and
569
+ // work signed out.
570
+ getFlags: () => this.flagsAdmin.getFlags(),
571
+ setFlagEnabled: (key: string, enabled: boolean) =>
572
+ this.flagsAdmin.setFlagEnabled(key, enabled),
573
+ setFlagUserVariant: (key: string, variant: string, remove: boolean, userId?: string) =>
574
+ this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
575
+ setLocalFlagOverride: (key: string, variant: string | null, payload?: unknown) =>
576
+ this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
577
+ clearLocalFlagOverrides: () => this.flagsAdmin.clearLocalFlagOverrides(),
235
578
  clearHistory: () => {
236
579
  this.eventsHistory = [];
237
580
  this.notifyDevTools();
238
581
  },
582
+ refreshVersions: () => this.refreshBackendVersions(),
583
+ getStorageInfo: (opts?: { tableCounts?: boolean }) => this.getStorageInfo(opts),
584
+ requestPersistentStorage: () => this.requestPersistentStorage(),
239
585
  getTableData: async (tableName: string) => {
240
586
  try {
241
587
  // Returns the first statement result as T.
@@ -270,7 +616,7 @@ export class DevToolsService implements StreamUpdateReceiver {
270
616
  return this.serializeForDevTools(records) || [];
271
617
  } catch (e) {
272
618
  this.logger.error(
273
- { err: e, Category: 'spooky-client::DevToolsService::exposeToWindow' },
619
+ { err: e, Category: 'sp00ky-client::DevToolsService::exposeToWindow' },
274
620
  'Failed to get table data'
275
621
  );
276
622
  return [];
@@ -299,7 +645,7 @@ export class DevToolsService implements StreamUpdateReceiver {
299
645
  runQuery: async (query: string, target: 'local' | 'remote' = 'local') => {
300
646
  try {
301
647
  this.logger.debug(
302
- { query, target, Category: 'spooky-client::DevToolsService::runQuery' },
648
+ { query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
303
649
  'Running query (START)'
304
650
  );
305
651
  const service = target === 'remote' ? this.remoteDatabaseService : this.databaseService;
@@ -314,7 +660,7 @@ export class DevToolsService implements StreamUpdateReceiver {
314
660
  time: queryTime,
315
661
  resultType: typeof result,
316
662
  isArray: Array.isArray(result),
317
- Category: 'spooky-client::DevToolsService::runQuery',
663
+ Category: 'sp00ky-client::DevToolsService::runQuery',
318
664
  },
319
665
  'Database returned result'
320
666
  );
@@ -328,7 +674,7 @@ export class DevToolsService implements StreamUpdateReceiver {
328
674
  {
329
675
  serializeTime,
330
676
  serializedLength: JSON.stringify(serialized).length,
331
- Category: 'spooky-client::DevToolsService::runQuery',
677
+ Category: 'sp00ky-client::DevToolsService::runQuery',
332
678
  },
333
679
  'Serialization complete'
334
680
  );
@@ -340,7 +686,7 @@ export class DevToolsService implements StreamUpdateReceiver {
340
686
  };
341
687
  } catch (e: any) {
342
688
  this.logger.error(
343
- { err: e, query, target, Category: 'spooky-client::DevToolsService::runQuery' },
689
+ { err: e, query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
344
690
  'Query execution failed'
345
691
  );
346
692
  // Ensure we always return a string for error
@@ -353,12 +699,15 @@ export class DevToolsService implements StreamUpdateReceiver {
353
699
 
354
700
  window.postMessage(
355
701
  {
356
- type: 'SPOOKY_DETECTED',
357
- source: 'spooky-devtools-page',
702
+ type: 'SP00KY_DETECTED',
703
+ source: 'sp00ky-devtools-page',
358
704
  data: { version: this.version, detected: true },
359
705
  },
360
706
  '*'
361
707
  );
708
+
709
+ // Dispatch custom event so the devtools page-script can detect late initialization
710
+ window.dispatchEvent(new CustomEvent('sp00ky:init'));
362
711
  }
363
712
  }
364
713
  }