@spooky-sync/core 0.0.1-canary.30 → 0.0.1-canary.300

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