@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.261

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 (231) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2355 -81
  4. package/dist/index.js +13247 -2595
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +793 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +187 -0
  23. package/src/boot/bucket-switch.saga.ts +131 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +386 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +507 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +161 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +9 -2
  53. package/src/modules/auth/impersonation-banner.ts +112 -0
  54. package/src/modules/auth/impersonation.test.ts +282 -0
  55. package/src/modules/auth/impersonation.ts +179 -0
  56. package/src/modules/auth/index.ts +344 -27
  57. package/src/modules/crdt/crdt-field.ts +294 -0
  58. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  59. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  60. package/src/modules/crdt/index.ts +463 -0
  61. package/src/modules/crdt/loro-loader.ts +25 -0
  62. package/src/modules/devtools/flags.ts +349 -0
  63. package/src/modules/devtools/index.ts +529 -47
  64. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  65. package/src/modules/devtools/state-shape.test.ts +149 -0
  66. package/src/modules/devtools/storage-info.test.ts +79 -0
  67. package/src/modules/devtools/storage-info.ts +168 -0
  68. package/src/modules/devtools/versions.test.ts +74 -0
  69. package/src/modules/devtools/versions.ts +110 -0
  70. package/src/modules/feature-flag/index.test.ts +251 -0
  71. package/src/modules/feature-flag/index.ts +316 -0
  72. package/src/modules/ref-tables.test.ts +91 -0
  73. package/src/modules/ref-tables.ts +88 -0
  74. package/src/mutation/jobs.test.ts +25 -0
  75. package/src/mutation/jobs.ts +41 -0
  76. package/src/mutation/mutation-id.test.ts +37 -0
  77. package/src/mutation/mutation-id.ts +35 -0
  78. package/src/mutation/push.saga.test.ts +285 -0
  79. package/src/mutation/push.saga.ts +215 -0
  80. package/src/mutation/rows.test.ts +201 -0
  81. package/src/mutation/rows.ts +377 -0
  82. package/src/mutation/tray.saga.test.ts +63 -0
  83. package/src/mutation/tray.saga.ts +48 -0
  84. package/src/mutation/write.saga.test.ts +163 -0
  85. package/src/mutation/write.saga.ts +166 -0
  86. package/src/otel/index.ts +13 -10
  87. package/src/query/env.test.ts +23 -0
  88. package/src/query/env.ts +56 -0
  89. package/src/query/fetch.saga.test.ts +156 -0
  90. package/src/query/fetch.saga.ts +115 -0
  91. package/src/query/hash.test.ts +14 -0
  92. package/src/query/hash.ts +18 -0
  93. package/src/query/id-set-plan.test.ts +132 -0
  94. package/src/query/lifecycle.saga.test.ts +122 -0
  95. package/src/query/lifecycle.saga.ts +101 -0
  96. package/src/query/materialize.saga.test.ts +82 -0
  97. package/src/query/materialize.saga.ts +75 -0
  98. package/src/query/materialize.test.ts +50 -0
  99. package/src/query/materialize.ts +44 -0
  100. package/src/query/membership.saga.test.ts +317 -0
  101. package/src/query/membership.saga.ts +262 -0
  102. package/src/query/membership.test.ts +118 -0
  103. package/src/query/membership.ts +171 -0
  104. package/src/query/register.saga.test.ts +293 -0
  105. package/src/query/register.saga.ts +226 -0
  106. package/src/query/render-set.test.ts +36 -0
  107. package/src/query/render-set.ts +60 -0
  108. package/src/query/sql.test.ts +62 -0
  109. package/src/query/sql.ts +79 -0
  110. package/src/query/window-query.test.ts +70 -0
  111. package/src/query/window-query.ts +195 -0
  112. package/src/services/blobs/blob-cache.test.ts +359 -0
  113. package/src/services/blobs/blob-cache.ts +603 -0
  114. package/src/services/blobs/blob-manifest.ts +227 -0
  115. package/src/services/blobs/blob-store.test.ts +77 -0
  116. package/src/services/blobs/blob-store.ts +359 -0
  117. package/src/services/blobs/blob.fixture.ts +90 -0
  118. package/src/services/blobs/index.ts +70 -0
  119. package/src/services/database/cache-engine.ts +199 -0
  120. package/src/services/database/connection-supervisor.test.ts +318 -0
  121. package/src/services/database/connection-supervisor.ts +422 -0
  122. package/src/services/database/database.query-timeout.test.ts +140 -0
  123. package/src/services/database/database.responses.test.ts +43 -0
  124. package/src/services/database/database.ts +145 -62
  125. package/src/services/database/engine-factory.ts +33 -0
  126. package/src/services/database/errors.ts +34 -0
  127. package/src/services/database/events/index.ts +2 -1
  128. package/src/services/database/index.ts +7 -0
  129. package/src/services/database/local-migrator.ts +30 -27
  130. package/src/services/database/local.test.ts +64 -0
  131. package/src/services/database/local.ts +491 -67
  132. package/src/services/database/plan-render.test.ts +159 -0
  133. package/src/services/database/plan-render.ts +108 -0
  134. package/src/services/database/relation-resolver.test.ts +467 -0
  135. package/src/services/database/relation-resolver.ts +0 -0
  136. package/src/services/database/remote.ts +222 -15
  137. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  138. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  139. package/src/services/database/sqlite-cache-engine.ts +1366 -0
  140. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  141. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  142. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  143. package/src/services/database/sqlite-lock-verify.ts +45 -0
  144. package/src/services/database/sqlite-open.test.ts +150 -0
  145. package/src/services/database/sqlite-open.ts +164 -0
  146. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  147. package/src/services/database/sqlite-plan-sql.ts +153 -0
  148. package/src/services/database/sqlite-projection.test.ts +121 -0
  149. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  150. package/src/services/database/sqlite-select.test.ts +246 -0
  151. package/src/services/database/sqlite-select.ts +131 -0
  152. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  153. package/src/services/database/sqlite-transport.ts +224 -0
  154. package/src/services/database/sqlite-worker.ts +437 -0
  155. package/src/services/database/surql-translate.ts +416 -0
  156. package/src/services/database/surreal-cache-engine.ts +161 -0
  157. package/src/services/logger/index.ts +3 -2
  158. package/src/services/persistence/localstorage.ts +2 -2
  159. package/src/services/persistence/resilient.ts +11 -4
  160. package/src/services/persistence/surrealdb.ts +10 -10
  161. package/src/services/stream-processor/index.ts +796 -84
  162. package/src/services/stream-processor/permissions.test.ts +47 -0
  163. package/src/services/stream-processor/permissions.ts +53 -0
  164. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  165. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  166. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  167. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  168. package/src/services/stream-processor/wasm-types.ts +59 -3
  169. package/src/services/tabs/broker-client.ts +283 -0
  170. package/src/services/tabs/broker.test.ts +327 -0
  171. package/src/services/tabs/coordinator.test.ts +365 -0
  172. package/src/services/tabs/coordinator.ts +633 -0
  173. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  174. package/src/services/tabs/leader-locks.ts +75 -0
  175. package/src/services/tabs/protocol.ts +262 -0
  176. package/src/services/tabs/support.ts +36 -0
  177. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  178. package/src/sp00ky.ts +5 -0
  179. package/src/state/client-state.ts +191 -0
  180. package/src/state/lifecycle.test.ts +118 -0
  181. package/src/state/lifecycle.ts +86 -0
  182. package/src/state/reducers.test.ts +280 -0
  183. package/src/state/reducers.ts +488 -0
  184. package/src/state/selectors.test.ts +149 -0
  185. package/src/state/selectors.ts +184 -0
  186. package/src/sync/connection.saga.test.ts +86 -0
  187. package/src/sync/connection.saga.ts +85 -0
  188. package/src/sync/live.saga.test.ts +178 -0
  189. package/src/sync/live.saga.ts +107 -0
  190. package/src/sync/policy.health.test.ts +56 -0
  191. package/src/sync/policy.test.ts +659 -0
  192. package/src/sync/policy.ts +502 -0
  193. package/src/sync/poll.saga.test.ts +51 -0
  194. package/src/sync/poll.saga.ts +36 -0
  195. package/src/sync/tabs.saga.test.ts +74 -0
  196. package/src/sync/tabs.saga.ts +93 -0
  197. package/src/testing/adapters.ts +133 -0
  198. package/src/testing/build.ts +63 -0
  199. package/src/testing/fake-services.ts +34 -0
  200. package/src/testing/run-pure.test.ts +92 -0
  201. package/src/testing/run-pure.ts +128 -0
  202. package/src/testing/services.ts +17 -0
  203. package/src/types.ts +591 -14
  204. package/src/utils/blurhash.ts +90 -0
  205. package/src/utils/error-classification.test.ts +54 -0
  206. package/src/utils/error-classification.ts +25 -0
  207. package/src/utils/index.ts +79 -13
  208. package/src/utils/parser.test.ts +49 -120
  209. package/src/utils/parser.ts +32 -2
  210. package/src/utils/semver.test.ts +32 -0
  211. package/src/utils/semver.ts +30 -0
  212. package/src/utils/surql.ts +30 -18
  213. package/src/utils/utils.test.ts +2 -1
  214. package/src/utils/withRetry.test.ts +1 -1
  215. package/tsdown.config.ts +86 -1
  216. package/vitest.config.ts +48 -0
  217. package/src/modules/cache/index.ts +0 -241
  218. package/src/modules/cache/types.ts +0 -19
  219. package/src/modules/data/data.test.ts +0 -58
  220. package/src/modules/data/index.ts +0 -879
  221. package/src/modules/sync/engine.ts +0 -173
  222. package/src/modules/sync/events/index.ts +0 -77
  223. package/src/modules/sync/index.ts +0 -3
  224. package/src/modules/sync/queue/index.ts +0 -2
  225. package/src/modules/sync/queue/queue-down.ts +0 -89
  226. package/src/modules/sync/queue/queue-up.ts +0 -223
  227. package/src/modules/sync/scheduler.ts +0 -84
  228. package/src/modules/sync/sync.ts +0 -421
  229. package/src/modules/sync/utils.test.ts +0 -311
  230. package/src/modules/sync/utils.ts +0 -167
  231. 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,198 @@ 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 { QueryState, QueryTimings } from '../../types';
17
+ import type {
18
+ AuthService,
19
+ ActiveImpersonation,
20
+ ImpersonationCandidate,
21
+ ImpersonationInfo,
22
+ } from '../auth/index';
18
23
  import { AuthEventTypes } from '../auth/events/index';
24
+ import {
25
+ type BackendInfo,
26
+ emptyBackendInfo,
27
+ parseBackendInfo,
28
+ UNAVAILABLE,
29
+ } from './versions';
30
+ import { walkOpfs, type BlobCacheInfo, type SharedTabsInfo, type StorageInfo } from './storage-info';
31
+ import { FlagsAdminService, type LocalOverrideStore } from './flags';
32
+
33
+ // Real bundled frontend versions, injected at build time by tsdown's
34
+ // version-define plugin (see tsdown.config.ts). The `typeof` guard keeps these
35
+ // from throwing a ReferenceError when a downstream app bundles core from source
36
+ // (where the plugin never runs); in that case they fall back to 'unknown' and
37
+ // DevTools simply reports an unknown frontend version instead of crashing.
38
+ const CORE_VERSION =
39
+ typeof __SP00KY_CORE_VERSION__ !== 'undefined' ? __SP00KY_CORE_VERSION__ : 'unknown';
40
+ const WASM_VERSION =
41
+ typeof __SP00KY_WASM_VERSION__ !== 'undefined' ? __SP00KY_WASM_VERSION__ : 'unknown';
42
+ const SURREAL_VERSION =
43
+ typeof __SP00KY_SURREAL_VERSION__ !== 'undefined' ? __SP00KY_SURREAL_VERSION__ : 'unknown';
44
+
45
+ /** What DevTools reads about live queries; the runtime derives it from state. */
46
+ export interface DevToolsQuerySource {
47
+ getActiveQueries(): QueryState[];
48
+ getQueryById(id: RecordId<string>): QueryState | undefined;
49
+ phaseTimings(q: QueryState): QueryTimings;
50
+ }
51
+
52
+ export type ImpersonationOp = 'status' | 'listUsers' | 'start' | 'stop';
53
+
54
+ export interface ImpersonationOpResult {
55
+ success: boolean;
56
+ error?: string;
57
+ /** Whether the generated schema says the project enabled impersonation. */
58
+ enabled?: boolean;
59
+ isAdmin?: boolean;
60
+ current?: ImpersonationInfo | null;
61
+ active?: ActiveImpersonation[];
62
+ users?: ImpersonationCandidate[];
63
+ }
19
64
 
20
65
  export class DevToolsService implements StreamUpdateReceiver {
21
66
  private eventsHistory: DevToolsEvent[] = [];
22
67
  private eventIdCounter = 0;
23
- private version = '1.0.0';
68
+ // Real bundled frontend version (injected at build time via tsdown `define`).
69
+ private version = CORE_VERSION;
70
+ // Backend stack info (versions + per-entity status), read via the
71
+ // `fn::spooky::info()` SurrealQL function; empty/'unavailable' until resolved.
72
+ private backendInfo: BackendInfo = emptyBackendInfo();
73
+ // Dormant until a devtools consumer (extension panel or MCP) handshakes via
74
+ // `SP00KY_DEVTOOLS_CONNECT`. While false, `notifyDevTools()`/`addEvent()` do no
75
+ // work, so prod pays zero serialization/postMessage cost for an unwatched panel.
76
+ // `window.__00__.getState()` stays live regardless, so the panel's first paint
77
+ // (the on-demand GET_STATE pull) still works before the push channel turns on.
78
+ private enabled = false;
79
+
80
+ // A state push serializes EVERY active query's full record set (see
81
+ // `getActiveQueries`) and postMessage clones it again, so its cost scales with
82
+ // the whole client dataset — and it is triggered per event, including one per
83
+ // local DB query (`DATABASE_LOCAL_QUERY` → `logEvent`). Unthrottled, a single
84
+ // page load's few hundred local queries turn a handful of MB of rows into GBs
85
+ // of short-lived large-object garbage and OOM the renderer (V8
86
+ // "young object promotion failed"). Coalesce instead: push immediately when
87
+ // idle, then at most once per window, always serializing the LATEST state.
88
+ private static readonly NOTIFY_MIN_INTERVAL_MS = 250;
89
+ private notifyTimer: ReturnType<typeof setTimeout> | null = null;
90
+ private lastNotifyAt = 0;
91
+ /** How many ids a pushed state carries per view; the rest is on demand. */
92
+ private static readonly STATE_IDS_CAP = 200;
93
+ /** devtools numeric hash -> the query's `_00_query` id, for on-demand rows. */
94
+ private hashToQuery = new Map<number, unknown>();
95
+
96
+ /** Shared-tabs snapshot for the panel, wired by Sp00kyClient whenever the
97
+ * feature was REQUESTED (so an inactive/degraded tab still reports why). */
98
+ private tabsInfoProvider: (() => SharedTabsInfo | null) | null = null;
99
+
100
+ setTabsInfoProvider(provider: () => SharedTabsInfo | null): void {
101
+ this.tabsInfoProvider = provider;
102
+ }
103
+
104
+ /** Blob cache counters for the panel, wired by Sp00kyClient. */
105
+ private blobInfoProvider: (() => BlobCacheInfo) | null = null;
106
+
107
+ setBlobInfoProvider(provider: () => BlobCacheInfo): void {
108
+ this.blobInfoProvider = provider;
109
+ }
110
+
111
+ // Full local table list (incl. internal `_00_*`), enumerated from the local DB
112
+ // via our own reliable `service.query` — the DevTools panel's page-eval query
113
+ // bridge is unreliable under load, so the panel relies on this instead.
114
+ private localTables: string[] = [];
115
+ private localTablesFetching = false;
116
+ private localTablesAt = 0;
117
+
118
+ // Feature flag admin, backing the panel's Access tab. The local-override
119
+ // store is injected later (`setFeatureFlagOverrides`) because the
120
+ // FeatureFlagModule is built after this service.
121
+ private featureOverrides: LocalOverrideStore | null = null;
122
+ private readonly flagsAdmin: FlagsAdminService;
24
123
 
25
124
  constructor(
26
- private databaseService: LocalDatabaseService,
125
+ private databaseService: LocalStore,
27
126
  private remoteDatabaseService: RemoteDatabaseService,
28
127
  private logger: Logger,
29
128
  private schema: SchemaStructure,
30
129
  private authService: AuthService<SchemaStructure>,
31
- private dataManager?: DataModule<SchemaStructure>
130
+ private dataManager?: DevToolsQuerySource
32
131
  ) {
132
+ this.flagsAdmin = new FlagsAdminService({
133
+ remote: this.remoteDatabaseService,
134
+ local: this.databaseService,
135
+ logger: this.logger,
136
+ currentUserId: () => {
137
+ const id = this.authService.currentUser?.id;
138
+ if (!id) return null;
139
+ return id instanceof RecordId ? encodeRecordId(id) : String(id);
140
+ },
141
+ overrides: () => this.featureOverrides,
142
+ });
143
+
33
144
  this.exposeToWindow();
34
145
 
35
- // Subscribe to auth events
146
+ // Stay dormant until a devtools consumer announces itself. The extension's
147
+ // page-script posts this once it detects `window.__00__`; the panel can also
148
+ // disconnect to return us to dormant. Until then we skip all serialization.
149
+ if (typeof window !== 'undefined') {
150
+ window.addEventListener('message', (e) => {
151
+ if (e.source !== window) return;
152
+ const type = (e.data as { type?: string } | undefined)?.type;
153
+ if (type === 'SP00KY_DEVTOOLS_CONNECT') {
154
+ this.enabled = true;
155
+ this.refreshLocalTables();
156
+ this.notifyDevTools();
157
+ } else if (type === 'SP00KY_DEVTOOLS_DISCONNECT') {
158
+ this.enabled = false;
159
+ }
160
+ });
161
+ }
162
+
163
+ // Subscribe to auth events. The initial fire-and-forget version fetch (below)
164
+ // races the remote connection; on the free plan the remote DB (SurrealDB
165
+ // Cloud) has no guest access, so `fn::spooky::info()` is only callable once
166
+ // signed in. Re-fetch when auth resolves — until the versions actually land —
167
+ // instead of leaving them 'unavailable' forever.
36
168
  this.authService.eventSystem.subscribe(AuthEventTypes.AuthStateChanged, () => {
37
- this.notifyDevTools();
169
+ if (this.authService.isAuthenticated && this.backendInfo.versions.ssp === UNAVAILABLE) {
170
+ void this.refreshBackendVersions();
171
+ } else {
172
+ this.notifyDevTools();
173
+ }
38
174
  });
39
175
 
40
- this.logger.debug({ Category: 'spooky-client::DevToolsService::init' }, 'Service initialized');
176
+ // Push state when the local store reports its durability (the open happens
177
+ // during connect, typically before a panel attaches, so this mostly matters
178
+ // for a later bucket switch that loses OPFS).
179
+ this.databaseService.subscribeToStorageHealth?.(() => this.notifyDevTools());
180
+
181
+ // Fire-and-forget backend version discovery; re-push state when it lands.
182
+ void this.refreshBackendVersions();
183
+
184
+ this.logger.debug({ Category: 'sp00ky-client::DevToolsService::init' }, 'Service initialized');
185
+ }
186
+
187
+ /**
188
+ * Re-read backend stack info via the `fn::spooky::info()` SurrealQL function
189
+ * over the open remote connection (no HTTP/CORS), then notify the panel.
190
+ * Never throws: on failure the info stays empty/'unavailable'.
191
+ */
192
+ private async refreshBackendVersions(): Promise<void> {
193
+ try {
194
+ // `RETURN fn::spooky::info()` → one statement result: the /info entity array.
195
+ const result = await this.remoteDatabaseService.query<unknown[]>(
196
+ 'RETURN fn::spooky::info()'
197
+ );
198
+ const first = Array.isArray(result) ? result[0] : result;
199
+ this.backendInfo = parseBackendInfo(first);
200
+ } catch (err) {
201
+ this.logger.debug(
202
+ { err, Category: 'sp00ky-client::DevToolsService::versions' },
203
+ 'fn::spooky::info() unavailable; backend versions stay unavailable'
204
+ );
205
+ this.backendInfo = emptyBackendInfo();
206
+ }
207
+ this.notifyDevTools();
41
208
  }
42
209
 
43
210
  // Get active queries directly from DataManager (single source of truth)
@@ -48,21 +215,52 @@ export class DevToolsService implements StreamUpdateReceiver {
48
215
  const queries = this.dataManager.getActiveQueries();
49
216
  queries.forEach((q) => {
50
217
  const queryHash = this.hashString(encodeRecordId(q.config.id));
218
+ const createdAt =
219
+ q.config.lastActiveAt instanceof Date
220
+ ? q.config.lastActiveAt.getTime()
221
+ : new Date(q.config.lastActiveAt || Date.now()).getTime();
222
+ this.hashToQuery.set(queryHash, q.config.id);
223
+ const localArray = q.config.localArray ?? [];
224
+ const remoteArray = q.config.remoteArray ?? [];
51
225
  result.set(queryHash, {
52
226
  queryHash,
53
227
  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(),
228
+ // Runtime fetch status, distinct from the `status: 'active'`
229
+ // registration flag above. `fetchStatus` is 'idle' | 'fetching'.
230
+ fetchStatus: q.status,
231
+ isFetching: q.status === 'fetching',
232
+ createdAt,
233
+ // Real last-update time; before the first update it equals createdAt.
234
+ // (Previously Date.now(), which reset the column on every state push.)
235
+ lastUpdate: q.lastUpdatedAt ?? createdAt,
59
236
  updateCount: q.updateCount,
237
+ ttl: q.config.ttl,
60
238
  query: q.config.surql,
61
239
  variables: q.config.params || {},
62
240
  dataSize: q.records?.length || 0,
63
- data: q.records,
64
- localArray: q.config.localArray,
65
- remoteArray: q.config.remoteArray,
241
+ // Counts and (capped) ids only. The rows themselves used to ride along
242
+ // here: every push then deep-cloned every view's records twice
243
+ // (serialize + postMessage), on a 4k-row client dataset up to four
244
+ // times a second, from inside the ingest call stack. The panel pulls
245
+ // rows on demand through `getQueryRows` instead.
246
+ localCount: localArray.length,
247
+ remoteCount: remoteArray.length,
248
+ localIds: localArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
249
+ remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
250
+ idsTruncated:
251
+ localArray.length > DevToolsService.STATE_IDS_CAP ||
252
+ remoteArray.length > DevToolsService.STATE_IDS_CAP,
253
+ // Membership state, so "why is this list empty" is answerable from
254
+ // the panel: is the server's set known, has a non-empty one been seen
255
+ // this session, how many empty reads were ignored.
256
+ membershipKnown: q.config.membershipKnown === true,
257
+ authoritative: q.config.membershipKnown === true,
258
+ viewLost: q.viewLost === true,
259
+ serverState: q.serverState ?? null,
260
+ // Detailed per-phase processing-time breakdown (SSP sub-phases, local/
261
+ // remote record fetch, frontend reconcile, registration). Flows to both
262
+ // the DevTools panel and the MCP (which returns activeQueries verbatim).
263
+ timings: this.dataManager!.phaseTimings(q),
66
264
  });
67
265
  });
68
266
  return result;
@@ -70,7 +268,7 @@ export class DevToolsService implements StreamUpdateReceiver {
70
268
 
71
269
  public onQueryInitialized(payload: any) {
72
270
  this.logger.debug(
73
- { payload, Category: 'spooky-client::DevToolsService::onQueryInitialized' },
271
+ { payload, Category: 'sp00ky-client::DevToolsService::onQueryInitialized' },
74
272
  'QueryInitialized'
75
273
  );
76
274
  const queryHash = this.hashString(payload.queryId.toString());
@@ -87,7 +285,7 @@ export class DevToolsService implements StreamUpdateReceiver {
87
285
  this.logger.debug(
88
286
  {
89
287
  id: payload.queryId?.toString(),
90
- Category: 'spooky-client::DevToolsService::onQueryUpdated',
288
+ Category: 'sp00ky-client::DevToolsService::onQueryUpdated',
91
289
  },
92
290
  'QueryUpdated'
93
291
  );
@@ -95,18 +293,28 @@ export class DevToolsService implements StreamUpdateReceiver {
95
293
 
96
294
  this.addEvent('QUERY_UPDATED', {
97
295
  queryHash,
98
- data: payload.records,
296
+ recordCount: Array.isArray(payload.records) ? payload.records.length : 0,
99
297
  });
100
298
  this.notifyDevTools();
101
299
  }
102
300
 
103
301
  public onStreamUpdate(update: StreamUpdate) {
302
+ // A synthetic re-materialize is not an ingest (DataModule.scheduleRematerialize).
303
+ if (update.synthetic) return;
104
304
  this.logger.debug(
105
- { update, Category: 'spooky-client::DevToolsService::onStreamUpdate' },
305
+ { queryHash: update.queryHash, Category: 'sp00ky-client::DevToolsService::onStreamUpdate' },
106
306
  'StreamUpdate'
107
307
  );
308
+ // Counts and timings, not the `localArray` itself: that is one [id, version]
309
+ // pair per row of the view, serialized on every single update.
108
310
  this.addEvent('STREAM_UPDATE', {
109
- updates: [update],
311
+ queryHash: update.queryHash,
312
+ op: update.op,
313
+ localCount: update.localArray?.length ?? 0,
314
+ materializationTimeMs: update.materializationTimeMs,
315
+ storeApplyMs: update.storeApplyMs,
316
+ circuitStepMs: update.circuitStepMs,
317
+ transformMs: update.transformMs,
110
318
  });
111
319
  this.notifyDevTools();
112
320
  }
@@ -116,8 +324,10 @@ export class DevToolsService implements StreamUpdateReceiver {
116
324
  payloads.forEach((p) => {
117
325
  this.addEvent('MUTATION_REQUEST_EXECUTION', {
118
326
  mutation: {
119
- type: 'create', // simplifying
120
- data: 'data' in p ? p.data : undefined,
327
+ type: p.type ?? 'create',
328
+ // Field names only; a payload body (a PGN, a document) is not
329
+ // something to clone on every write.
330
+ fields: 'data' in p && p.data && typeof p.data === 'object' ? Object.keys(p.data) : [],
121
331
  selector: encodeRecordId(p.record_id),
122
332
  },
123
333
  });
@@ -142,6 +352,8 @@ export class DevToolsService implements StreamUpdateReceiver {
142
352
  }
143
353
 
144
354
  private addEvent(eventType: string, payload: any) {
355
+ // No consumer attached → skip recording (and the recursive serialize it does).
356
+ if (!this.enabled) return;
145
357
  this.eventsHistory.push({
146
358
  id: this.eventIdCounter++,
147
359
  timestamp: Date.now(),
@@ -151,33 +363,251 @@ export class DevToolsService implements StreamUpdateReceiver {
151
363
  if (this.eventsHistory.length > 100) this.eventsHistory.shift();
152
364
  }
153
365
 
154
- private getState() {
366
+ /** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
367
+ private unwrapInfo(res: any): any {
368
+ if (!Array.isArray(res) || !res[0]) return null;
369
+ const first = res[0];
370
+ if (first && typeof first === 'object' && 'result' in first) return first.result;
371
+ if (Array.isArray(first)) return first[0];
372
+ return first;
373
+ }
374
+
375
+ /**
376
+ * Refresh the cached full local-table list from `INFO FOR DB`. Fire-and-forget
377
+ * and throttled — called from getState() so the panel gets every table
378
+ * (including internal `_00_*`) without running its own (flaky) queries.
379
+ */
380
+ private refreshLocalTables(): void {
381
+ if (this.localTablesFetching) return;
382
+ const now = Date.now();
383
+ if (now - this.localTablesAt < 30_000) return;
384
+ this.localTablesFetching = true;
385
+ void this.databaseService
386
+ .query<any>('INFO FOR DB')
387
+ .then((res) => {
388
+ const info = this.unwrapInfo(res);
389
+ this.localTablesAt = Date.now();
390
+ if (info && info.tables) {
391
+ // The circuit snapshot table holds a BLOB, not JSON rows; the
392
+ // explorer cannot render it and has nothing to show for it.
393
+ const names = Object.keys(info.tables).filter((n) => n !== '_00_circuit_snapshot');
394
+ const changed =
395
+ names.length !== this.localTables.length ||
396
+ names.some((n, i) => n !== this.localTables[i]);
397
+ this.localTables = names;
398
+ if (changed) this.notifyDevTools();
399
+ }
400
+ })
401
+ .catch(() => {
402
+ // Ignore — fall back to the declared app schema below.
403
+ })
404
+ .finally(() => {
405
+ this.localTablesFetching = false;
406
+ });
407
+ }
408
+
409
+ private getState(opts: { refreshTables?: boolean } = {}) {
410
+ // The local-table list (`INFO FOR DB`, a local round trip) is refreshed on
411
+ // an explicit pull only, never by the push path: pushes follow every sync
412
+ // event, and a local query per push competed with the app's own writes for
413
+ // the single local op queue.
414
+ if (opts.refreshTables) this.refreshLocalTables();
155
415
  return this.serializeForDevTools({
156
416
  eventsHistory: [...this.eventsHistory],
157
417
  activeQueries: Object.fromEntries(this.getActiveQueries()),
158
418
  auth: {
159
419
  authenticated: this.authService.isAuthenticated,
160
420
  userId: this.authService.currentUser?.id,
421
+ impersonation: this.authService.impersonation,
161
422
  },
162
423
  version: this.version,
424
+ versions: {
425
+ frontend: {
426
+ core: CORE_VERSION,
427
+ wasm: WASM_VERSION,
428
+ surrealdb: SURREAL_VERSION,
429
+ },
430
+ backend: this.backendInfo.versions,
431
+ entities: this.backendInfo.entities,
432
+ },
163
433
  database: {
164
- tables: this.schema.tables.map((t) => t.name),
434
+ // Prefer the live local-table list (includes internal `_00_*`); fall
435
+ // back to the declared app schema until the first enumeration lands.
436
+ tables: this.localTables.length
437
+ ? this.localTables
438
+ : this.schema.tables.map((t) => t.name),
165
439
  tableData: {},
440
+ // Which backend answers "Local". The Database explorer labels its source
441
+ // picker with it and explains translation failures against `sqlite`,
442
+ // whose SurrealQL vocabulary is a bounded subset.
443
+ engine: this.databaseService.engineKind ?? 'custom',
444
+ // Durability of the local store. `fallback: true` means persistence was
445
+ // requested but the dataset is actually sitting in RAM.
446
+ storage: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
447
+ // Shared-tabs role state (null when the feature is off / fell back).
448
+ tabs: this.tabsInfoProvider?.() ?? null,
166
449
  },
167
450
  });
168
451
  }
169
452
 
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
- '*'
453
+ /**
454
+ * The Access tab's impersonation controls. Errors come back as
455
+ * `{ success: false, error }` with the server's own message, so the panel
456
+ * can show why (not an admin, feature disabled, target is an admin).
457
+ */
458
+ public async impersonationOp(
459
+ op: ImpersonationOp,
460
+ args: Record<string, unknown>
461
+ ): Promise<ImpersonationOpResult> {
462
+ const enabled = this.schema.policy?.impersonation === true;
463
+ try {
464
+ switch (op) {
465
+ case 'status': {
466
+ const current = this.authService.impersonation;
467
+ const snapshot = await this.flagsAdmin.getFlags().catch(() => null);
468
+ const isAdmin = snapshot?.isAdmin === true;
469
+ const active =
470
+ enabled && isAdmin && !current
471
+ ? await this.authService.listActiveImpersonations().catch(() => [])
472
+ : [];
473
+ return this.serializeForDevTools({ success: true, enabled, isAdmin, current, active });
474
+ }
475
+ case 'listUsers':
476
+ return this.serializeForDevTools({
477
+ success: true,
478
+ users: await this.authService.searchImpersonationTargets(String(args.search ?? '')),
479
+ });
480
+ case 'start': {
481
+ const current = await this.authService.impersonate(
482
+ String(args.target ?? ''),
483
+ String(args.reason ?? '')
484
+ );
485
+ return this.serializeForDevTools({ success: true, current });
486
+ }
487
+ case 'stop':
488
+ await this.authService.stopImpersonating();
489
+ return { success: true, current: null };
490
+ default:
491
+ return { success: false, error: `Unknown impersonation op: ${String(op)}` };
492
+ }
493
+ } catch (e) {
494
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Full storage diagnostics for the DevTools Storage tab. Every section is
500
+ * gathered independently and failures land in that section's `error` field,
501
+ * so one broken source (a mid-switch worker, a browser without OPFS) never
502
+ * blanks the whole panel.
503
+ */
504
+ public async getStorageInfo(opts?: { tableCounts?: boolean }): Promise<StorageInfo> {
505
+ const nav = typeof navigator !== 'undefined' ? navigator : undefined;
506
+
507
+ const info: StorageInfo = {
508
+ at: Date.now(),
509
+ engine: {
510
+ kind: this.databaseService.engineKind ?? 'custom',
511
+ store: this.databaseService.getConfig()?.store ?? 'memory',
512
+ bucketId: this.databaseService.currentBucketId,
513
+ },
514
+ health: this.databaseService.storageHealth ?? { status: 'unknown', fallback: false },
515
+ tabs: this.tabsInfoProvider?.() ?? null,
516
+ browser: {},
517
+ opfs: { supported: false, entries: [], totalBytes: 0, truncated: false },
518
+ };
519
+
520
+ try {
521
+ if (nav?.storage?.estimate) {
522
+ const est = await nav.storage.estimate();
523
+ info.browser.usage = est.usage;
524
+ info.browser.quota = est.quota;
525
+ // Chrome-only per-storage-system breakdown; absent elsewhere.
526
+ const details = (est as any).usageDetails;
527
+ if (details && typeof details === 'object') info.browser.usageDetails = details;
528
+ }
529
+ if (nav?.storage?.persisted) {
530
+ info.browser.persisted = await nav.storage.persisted();
531
+ }
532
+ } catch (e) {
533
+ info.browser.error = e instanceof Error ? e.message : String(e);
534
+ }
535
+
536
+ info.opfs = await walkOpfs();
537
+
538
+ try {
539
+ info.blobs = this.blobInfoProvider?.();
540
+ } catch (e) {
541
+ this.logger.warn(
542
+ { err: e, Category: 'sp00ky-client::DevToolsService::getStorageInfo' },
543
+ 'Blob cache diagnostics failed'
179
544
  );
180
545
  }
546
+
547
+ const stats = (globalThis as any).__sqliteStats;
548
+ if (stats && typeof stats === 'object') {
549
+ info.sqliteStats = { ...stats, byType: { ...(stats.byType ?? {}) } };
550
+ }
551
+
552
+ try {
553
+ info.engineDiagnostics = await this.databaseService.getStorageDiagnostics?.(opts);
554
+ } catch (e) {
555
+ this.logger.warn(
556
+ { err: e, Category: 'sp00ky-client::DevToolsService::getStorageInfo' },
557
+ 'Engine storage diagnostics failed'
558
+ );
559
+ }
560
+
561
+ return this.serializeForDevTools(info);
562
+ }
563
+
564
+ /** Ask the browser to exempt this origin's storage from eviction. */
565
+ public async requestPersistentStorage(): Promise<{ granted: boolean }> {
566
+ try {
567
+ const granted = (await navigator.storage?.persist?.()) ?? false;
568
+ return { granted };
569
+ } catch {
570
+ return { granted: false };
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Request a state push. Coalesced (see {@link NOTIFY_MIN_INTERVAL_MS}): the
576
+ * first call after an idle period pushes straight away so the panel stays
577
+ * responsive, and any calls during the window collapse into ONE trailing push
578
+ * that serializes the state as of the flush, not as of the request. Callers
579
+ * stay fire-and-forget.
580
+ */
581
+ private notifyDevTools() {
582
+ // No consumer attached → no getState() serialization, no postMessage broadcast.
583
+ if (!this.enabled) return;
584
+ if (typeof window === 'undefined') return;
585
+ // A trailing push is already queued; it will carry this change too.
586
+ if (this.notifyTimer !== null) return;
587
+
588
+ // Always a macrotask, never inline: this is called from inside the ingest
589
+ // and mutation call stacks (onStreamUpdate / onMutation), i.e. inside the
590
+ // `await db.create(...)` the app is waiting on. A push that serializes the
591
+ // state right there charged every write for the panel's refresh.
592
+ const waited = Date.now() - this.lastNotifyAt;
593
+ const delay = Math.max(0, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
594
+ this.notifyTimer = setTimeout(() => {
595
+ this.notifyTimer = null;
596
+ // Still gated on `enabled`: the panel may have disconnected while queued.
597
+ if (this.enabled) this.flushNotify();
598
+ }, delay);
599
+ }
600
+
601
+ private flushNotify() {
602
+ this.lastNotifyAt = Date.now();
603
+ window.postMessage(
604
+ {
605
+ type: 'SP00KY_STATE_CHANGED',
606
+ source: 'sp00ky-devtools-page',
607
+ state: this.getState(),
608
+ },
609
+ '*'
610
+ );
181
611
  }
182
612
 
183
613
  private serializeForDevTools(data: any, seen = new WeakSet<object>()): any {
@@ -218,6 +648,10 @@ export class DevToolsService implements StreamUpdateReceiver {
218
648
  const result: Record<string, any> = {};
219
649
  for (const key in data) {
220
650
  if (Object.prototype.hasOwnProperty.call(data, key)) {
651
+ // Skip absent optional fields: recursing them would emit the STRING
652
+ // 'undefined' (the top-level mapping below), which panels then have
653
+ // to filter back out (see 3d84fe8a).
654
+ if (data[key] === undefined) continue;
221
655
  result[key] = this.serializeForDevTools(data[key], seen);
222
656
  }
223
657
  }
@@ -227,15 +661,60 @@ export class DevToolsService implements StreamUpdateReceiver {
227
661
  return data;
228
662
  }
229
663
 
664
+ /**
665
+ * Hand the FeatureFlagModule to the Access tab so it can read and write local
666
+ * overrides. Called from `Sp00kyClient` once both are constructed; until then
667
+ * the override methods are no-ops that report an empty map.
668
+ */
669
+ public setFeatureFlagOverrides(store: LocalOverrideStore): void {
670
+ this.featureOverrides = store;
671
+ }
672
+
230
673
  private exposeToWindow() {
231
674
  if (typeof window !== 'undefined') {
232
- (window as any).__SPOOKY__ = {
675
+ (window as any).__00__ = {
233
676
  version: this.version,
234
- getState: () => this.getState(),
677
+ getState: () => this.getState({ refreshTables: true }),
678
+ // The rows of ONE view, on demand. The pushed state carries counts and
679
+ // capped ids only (see getActiveQueries); the panel's Data tab and the
680
+ // MCP fetch the rows here when somebody actually looks at them.
681
+ getQueryRows: (queryHash: number) => {
682
+ const id = this.hashToQuery.get(Number(queryHash));
683
+ const q = id !== undefined ? this.dataManager?.getQueryById(id as any) : undefined;
684
+ if (!q) return null;
685
+ return this.serializeForDevTools({
686
+ queryHash: Number(queryHash),
687
+ data: q.records,
688
+ localArray: q.config.localArray,
689
+ remoteArray: q.config.remoteArray,
690
+ });
691
+ },
692
+ // ---- Feature flags (Access tab) --------------------------------
693
+ // Remote reads/writes are admin-gated by SurrealDB, not here: a
694
+ // non-admin gets an empty flag list, and the `fn::feature::*` calls
695
+ // are denied outright. The override methods are purely local and
696
+ // work signed out.
697
+ getFlags: () => this.flagsAdmin.getFlags(),
698
+ // ---- Impersonation (Access tab) --------------------------------
699
+ // Every op is authorized by SurrealDB (`fn::_00_impersonate::*` are
700
+ // admin-only and absent unless the project enabled the feature);
701
+ // nothing here grants anything.
702
+ impersonationOp: (op: ImpersonationOp, args?: Record<string, unknown>) =>
703
+ this.impersonationOp(op, args ?? {}),
704
+ setFlagEnabled: (key: string, enabled: boolean) =>
705
+ this.flagsAdmin.setFlagEnabled(key, enabled),
706
+ setFlagUserVariant: (key: string, variant: string, remove: boolean, userId?: string) =>
707
+ this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
708
+ setLocalFlagOverride: (key: string, variant: string | null, payload?: unknown) =>
709
+ this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
710
+ clearLocalFlagOverrides: () => this.flagsAdmin.clearLocalFlagOverrides(),
235
711
  clearHistory: () => {
236
712
  this.eventsHistory = [];
237
713
  this.notifyDevTools();
238
714
  },
715
+ refreshVersions: () => this.refreshBackendVersions(),
716
+ getStorageInfo: (opts?: { tableCounts?: boolean }) => this.getStorageInfo(opts),
717
+ requestPersistentStorage: () => this.requestPersistentStorage(),
239
718
  getTableData: async (tableName: string) => {
240
719
  try {
241
720
  // Returns the first statement result as T.
@@ -270,7 +749,7 @@ export class DevToolsService implements StreamUpdateReceiver {
270
749
  return this.serializeForDevTools(records) || [];
271
750
  } catch (e) {
272
751
  this.logger.error(
273
- { err: e, Category: 'spooky-client::DevToolsService::exposeToWindow' },
752
+ { err: e, Category: 'sp00ky-client::DevToolsService::exposeToWindow' },
274
753
  'Failed to get table data'
275
754
  );
276
755
  return [];
@@ -299,7 +778,7 @@ export class DevToolsService implements StreamUpdateReceiver {
299
778
  runQuery: async (query: string, target: 'local' | 'remote' = 'local') => {
300
779
  try {
301
780
  this.logger.debug(
302
- { query, target, Category: 'spooky-client::DevToolsService::runQuery' },
781
+ { query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
303
782
  'Running query (START)'
304
783
  );
305
784
  const service = target === 'remote' ? this.remoteDatabaseService : this.databaseService;
@@ -314,7 +793,7 @@ export class DevToolsService implements StreamUpdateReceiver {
314
793
  time: queryTime,
315
794
  resultType: typeof result,
316
795
  isArray: Array.isArray(result),
317
- Category: 'spooky-client::DevToolsService::runQuery',
796
+ Category: 'sp00ky-client::DevToolsService::runQuery',
318
797
  },
319
798
  'Database returned result'
320
799
  );
@@ -328,7 +807,7 @@ export class DevToolsService implements StreamUpdateReceiver {
328
807
  {
329
808
  serializeTime,
330
809
  serializedLength: JSON.stringify(serialized).length,
331
- Category: 'spooky-client::DevToolsService::runQuery',
810
+ Category: 'sp00ky-client::DevToolsService::runQuery',
332
811
  },
333
812
  'Serialization complete'
334
813
  );
@@ -340,7 +819,7 @@ export class DevToolsService implements StreamUpdateReceiver {
340
819
  };
341
820
  } catch (e: any) {
342
821
  this.logger.error(
343
- { err: e, query, target, Category: 'spooky-client::DevToolsService::runQuery' },
822
+ { err: e, query, target, Category: 'sp00ky-client::DevToolsService::runQuery' },
344
823
  'Query execution failed'
345
824
  );
346
825
  // Ensure we always return a string for error
@@ -353,12 +832,15 @@ export class DevToolsService implements StreamUpdateReceiver {
353
832
 
354
833
  window.postMessage(
355
834
  {
356
- type: 'SPOOKY_DETECTED',
357
- source: 'spooky-devtools-page',
835
+ type: 'SP00KY_DETECTED',
836
+ source: 'sp00ky-devtools-page',
358
837
  data: { version: this.version, detected: true },
359
838
  },
360
839
  '*'
361
840
  );
841
+
842
+ // Dispatch custom event so the devtools page-script can detect late initialization
843
+ window.dispatchEvent(new CustomEvent('sp00ky:init'));
362
844
  }
363
845
  }
364
846
  }