@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +84 -0
- package/README.md +56 -16
- package/dist/index.d.ts +2080 -142
- package/dist/index.js +16009 -2480
- package/dist/live.d.ts +149 -0
- package/dist/live.js +739 -0
- package/dist/otel/index.d.ts +1 -1
- package/dist/otel/index.js +3 -3
- package/dist/pure.d.ts +373 -0
- package/dist/pure.js +24 -0
- package/dist/sqlite-open.js +392 -0
- package/dist/sqlite-worker.d.ts +1 -0
- package/dist/sqlite-worker.js +439 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/tabs-broker-worker.d.ts +8 -0
- package/dist/tabs-broker-worker.js +477 -0
- package/dist/types.d.ts +1857 -38
- package/dist/worker-edges.js +480 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +48 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +28 -7
- package/scripts/check-broker-bundle.mjs +33 -0
- package/scripts/check-worker-entries.mjs +173 -0
- package/skills/sp00ky-core/SKILL.md +32 -7
- package/skills/sp00ky-core/references/config.md +7 -1
- package/src/boot/auth-flip.saga.test.ts +135 -0
- package/src/boot/auth-flip.saga.ts +77 -0
- package/src/boot/boot.saga.test.ts +203 -0
- package/src/boot/boot.saga.ts +146 -0
- package/src/boot/bucket-switch.saga.test.ts +187 -0
- package/src/boot/bucket-switch.saga.ts +133 -0
- package/src/boot/preload.saga.test.ts +80 -0
- package/src/boot/preload.saga.ts +40 -0
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/build-globals.d.ts +12 -0
- package/src/client/bucket-handle.test.ts +195 -0
- package/src/client/bucket-handle.ts +352 -0
- package/src/client/router.test.ts +58 -0
- package/src/client/router.ts +100 -0
- package/src/client/runtime.test.ts +195 -0
- package/src/client/runtime.ts +296 -0
- package/src/client/services.test.ts +74 -0
- package/src/client/services.ts +434 -0
- package/src/client/sp00ky-client.test.ts +341 -0
- package/src/client/sp00ky-client.ts +625 -0
- package/src/events/events.test.ts +2 -1
- package/src/events/index.ts +3 -0
- package/src/index.ts +69 -1
- package/src/kernel/constants.test.ts +19 -0
- package/src/kernel/constants.ts +80 -0
- package/src/kernel/effects.test.ts +40 -0
- package/src/kernel/effects.ts +167 -0
- package/src/kernel/events.ts +57 -0
- package/src/kernel/interpreter.test.ts +116 -0
- package/src/kernel/interpreter.ts +148 -0
- package/src/kernel/saga.test.ts +89 -0
- package/src/kernel/saga.ts +73 -0
- package/src/live/index.test.ts +512 -0
- package/src/live/index.ts +1059 -0
- package/src/modules/app-release/index.test.ts +125 -0
- package/src/modules/app-release/index.ts +209 -0
- package/src/modules/auth/auth.local-first.test.ts +228 -0
- package/src/modules/auth/events/index.ts +9 -2
- package/src/modules/auth/impersonation-banner.ts +443 -0
- package/src/modules/auth/impersonation.integration.test.ts +205 -0
- package/src/modules/auth/impersonation.test.ts +377 -0
- package/src/modules/auth/impersonation.ts +179 -0
- package/src/modules/auth/index.ts +476 -22
- package/src/modules/crdt/acceptance.ts +75 -0
- package/src/modules/crdt/bytes.ts +50 -0
- package/src/modules/crdt/crdt-field.ts +125 -0
- package/src/modules/crdt/crdt.test.ts +469 -0
- package/src/modules/crdt/hash.ts +103 -0
- package/src/modules/crdt/index.ts +757 -0
- package/src/modules/crdt/loro-loader.ts +25 -0
- package/src/modules/crdt/mirror.ts +37 -0
- package/src/modules/devtools/flags.ts +349 -0
- package/src/modules/devtools/index.ts +746 -95
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +166 -0
- package/src/modules/devtools/state-shape.test.ts +263 -0
- package/src/modules/devtools/storage-info.test.ts +79 -0
- package/src/modules/devtools/storage-info.ts +168 -0
- package/src/modules/devtools/versions.test.ts +74 -0
- package/src/modules/devtools/versions.ts +110 -0
- package/src/modules/feature-flag/index.test.ts +298 -0
- package/src/modules/feature-flag/index.ts +316 -0
- package/src/modules/ref-tables.test.ts +93 -0
- package/src/modules/ref-tables.ts +91 -0
- package/src/modules/web-push/index.test.ts +824 -0
- package/src/modules/web-push/index.ts +1027 -0
- package/src/mutation/jobs.test.ts +25 -0
- package/src/mutation/jobs.ts +41 -0
- package/src/mutation/mutation-id.test.ts +37 -0
- package/src/mutation/mutation-id.ts +35 -0
- package/src/mutation/pending-writes.integration.test.ts +175 -0
- package/src/mutation/push.saga.test.ts +301 -0
- package/src/mutation/push.saga.ts +225 -0
- package/src/mutation/rows.test.ts +231 -0
- package/src/mutation/rows.ts +427 -0
- package/src/mutation/tray.saga.test.ts +63 -0
- package/src/mutation/tray.saga.ts +48 -0
- package/src/mutation/write.saga.test.ts +316 -0
- package/src/mutation/write.saga.ts +268 -0
- package/src/otel/index.ts +10 -7
- package/src/pure.ts +66 -0
- package/src/push/types.ts +174 -0
- package/src/query/anon-query-isolation.integration.test.ts +527 -0
- package/src/query/env.test.ts +23 -0
- package/src/query/env.ts +65 -0
- package/src/query/fetch.saga.test.ts +199 -0
- package/src/query/fetch.saga.ts +120 -0
- package/src/query/hash.test.ts +14 -0
- package/src/query/hash.ts +18 -0
- package/src/query/id-set-plan.test.ts +132 -0
- package/src/query/lifecycle.saga.test.ts +274 -0
- package/src/query/lifecycle.saga.ts +186 -0
- package/src/query/list-ref-live.integration.test.ts +203 -0
- package/src/query/materialize.saga.test.ts +115 -0
- package/src/query/materialize.saga.ts +84 -0
- package/src/query/materialize.test.ts +58 -0
- package/src/query/materialize.ts +57 -0
- package/src/query/membership.saga.test.ts +407 -0
- package/src/query/membership.saga.ts +282 -0
- package/src/query/membership.test.ts +118 -0
- package/src/query/membership.ts +201 -0
- package/src/query/register.saga.test.ts +311 -0
- package/src/query/register.saga.ts +235 -0
- package/src/query/render-set.test.ts +47 -0
- package/src/query/render-set.ts +71 -0
- package/src/query/sql.test.ts +118 -0
- package/src/query/sql.ts +157 -0
- package/src/query/window-query.test.ts +70 -0
- package/src/query/window-query.ts +208 -0
- package/src/services/blobs/blob-cache.test.ts +359 -0
- package/src/services/blobs/blob-cache.ts +603 -0
- package/src/services/blobs/blob-manifest.ts +227 -0
- package/src/services/blobs/blob-store.test.ts +77 -0
- package/src/services/blobs/blob-store.ts +359 -0
- package/src/services/blobs/blob.fixture.ts +90 -0
- package/src/services/blobs/index.ts +70 -0
- package/src/services/database/cache-engine.ts +209 -0
- package/src/services/database/connection-supervisor.test.ts +318 -0
- package/src/services/database/connection-supervisor.ts +422 -0
- package/src/services/database/database.query-timeout.test.ts +140 -0
- package/src/services/database/database.responses.test.ts +43 -0
- package/src/services/database/database.ts +156 -61
- package/src/services/database/engine-factory.ts +33 -0
- package/src/services/database/errors.ts +34 -0
- package/src/services/database/events/index.ts +2 -1
- package/src/services/database/index.ts +7 -0
- package/src/services/database/landed-body-clear.integration.test.ts +236 -0
- package/src/services/database/local-migrator.integration.test.ts +110 -0
- package/src/services/database/local-migrator.ts +61 -16
- package/src/services/database/local.test.ts +150 -0
- package/src/services/database/local.ts +508 -63
- package/src/services/database/plan-render.test.ts +159 -0
- package/src/services/database/plan-render.ts +108 -0
- package/src/services/database/relation-resolver.test.ts +484 -0
- package/src/services/database/relation-resolver.ts +0 -0
- package/src/services/database/remote.ts +285 -6
- package/src/services/database/sqlite-cache-engine.test.ts +628 -0
- package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
- package/src/services/database/sqlite-cache-engine.ts +1401 -0
- package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
- package/src/services/database/sqlite-column-reset.ts +91 -0
- package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
- package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
- package/src/services/database/sqlite-lock-verify.test.ts +33 -0
- package/src/services/database/sqlite-lock-verify.ts +45 -0
- package/src/services/database/sqlite-open.test.ts +150 -0
- package/src/services/database/sqlite-open.ts +164 -0
- package/src/services/database/sqlite-plan-sql.test.ts +164 -0
- package/src/services/database/sqlite-plan-sql.ts +196 -0
- package/src/services/database/sqlite-projection.test.ts +121 -0
- package/src/services/database/sqlite-select.integration.test.ts +185 -0
- package/src/services/database/sqlite-select.test.ts +261 -0
- package/src/services/database/sqlite-select.ts +131 -0
- package/src/services/database/sqlite-transport.fixture.ts +30 -0
- package/src/services/database/sqlite-transport.ts +224 -0
- package/src/services/database/sqlite-worker.ts +437 -0
- package/src/services/database/surql-translate.ts +417 -0
- package/src/services/database/surreal-cache-engine.ts +163 -0
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +189 -7
- package/src/services/persistence/localstorage.ts +2 -2
- package/src/services/persistence/resilient.ts +10 -3
- package/src/services/persistence/surrealdb.ts +4 -4
- package/src/services/stream-processor/index.ts +806 -67
- package/src/services/stream-processor/permissions.test.ts +47 -0
- package/src/services/stream-processor/permissions.ts +53 -0
- package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
- package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
- package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
- package/src/services/stream-processor/stream-processor.test.ts +1 -1
- package/src/services/stream-processor/wasm-types.ts +58 -2
- package/src/services/tabs/broker-client.ts +284 -0
- package/src/services/tabs/broker.test.ts +381 -0
- package/src/services/tabs/coordinator.test.ts +386 -0
- package/src/services/tabs/coordinator.ts +646 -0
- package/src/services/tabs/fake-ports.fixture.ts +112 -0
- package/src/services/tabs/leader-locks.ts +75 -0
- package/src/services/tabs/protocol.ts +262 -0
- package/src/services/tabs/support.ts +36 -0
- package/src/services/tabs/tabs-broker-worker.ts +652 -0
- package/src/sp00ky.ts +5 -395
- package/src/state/client-state.ts +208 -0
- package/src/state/lifecycle.test.ts +141 -0
- package/src/state/lifecycle.ts +99 -0
- package/src/state/reducers.test.ts +306 -0
- package/src/state/reducers.ts +531 -0
- package/src/state/selectors.test.ts +188 -0
- package/src/state/selectors.ts +258 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/sync/connection.saga.test.ts +97 -0
- package/src/sync/connection.saga.ts +89 -0
- package/src/sync/live.saga.test.ts +178 -0
- package/src/sync/live.saga.ts +107 -0
- package/src/sync/policy.health.test.ts +56 -0
- package/src/sync/policy.test.ts +663 -0
- package/src/sync/policy.ts +515 -0
- package/src/sync/poll.saga.test.ts +51 -0
- package/src/sync/poll.saga.ts +36 -0
- package/src/sync/tabs.saga.test.ts +78 -0
- package/src/sync/tabs.saga.ts +96 -0
- package/src/testing/adapters.ts +137 -0
- package/src/testing/build.ts +63 -0
- package/src/testing/fake-services.ts +63 -0
- package/src/testing/run-pure.test.ts +92 -0
- package/src/testing/run-pure.ts +128 -0
- package/src/testing/services.ts +17 -0
- package/src/types.ts +633 -10
- package/src/utils/blurhash.ts +90 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/error-classification.test.ts +54 -0
- package/src/utils/error-classification.ts +25 -0
- package/src/utils/index.ts +89 -12
- package/src/utils/parser.test.ts +67 -102
- package/src/utils/parser.ts +64 -2
- package/src/utils/semver.test.ts +32 -0
- package/src/utils/semver.ts +30 -0
- package/src/utils/sha256.ts +15 -0
- package/src/utils/surql.ts +29 -18
- package/src/utils/utils.test.ts +3 -2
- package/tsdown.config.ts +117 -3
- package/vitest.config.ts +48 -0
- package/src/modules/cache/index.ts +0 -241
- package/src/modules/cache/types.ts +0 -19
- package/src/modules/data/data.test.ts +0 -58
- package/src/modules/data/index.ts +0 -879
- package/src/modules/sync/engine.ts +0 -173
- package/src/modules/sync/events/index.ts +0 -77
- package/src/modules/sync/index.ts +0 -3
- package/src/modules/sync/queue/index.ts +0 -2
- package/src/modules/sync/queue/queue-down.ts +0 -89
- package/src/modules/sync/queue/queue-up.ts +0 -223
- package/src/modules/sync/scheduler.ts +0 -84
- package/src/modules/sync/sync.ts +0 -421
- package/src/modules/sync/utils.test.ts +0 -311
- package/src/modules/sync/utils.ts +0 -167
|
@@ -1,45 +1,276 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
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:
|
|
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?:
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
{
|
|
74
|
-
|
|
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
|
-
|
|
351
|
+
this.notifyDevTools();
|
|
352
|
+
}
|
|
77
353
|
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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.
|