Skip to content

Commit 7a79a55

Browse files
committed
feat(data-inspector): inline sources, lazy deep-node expansion, auto-rerun, playground toolbar
- exposeDataInspector accepts a sources[] option, registering them before the endpoint opens (a convenience over calling registerDataSource separately) - lazy-expand depth-truncated nodes: the normalizer records a NodePath on each depth marker, a new queryPath RPC re-runs the query and returns a fresh slice of that subtree, and the viewer splices it in below a 'load deeper' link - auto rerun: an optional poller under the filters re-runs the query every N seconds against the live object; persisted in the URL, skipped while a run is in flight or the query is broken - richer result/query toolbar: copy query, expand-all/collapse-all, copy JSON
1 parent dea1523 commit 7a79a55

21 files changed

Lines changed: 620 additions & 47 deletions

File tree

docs/plugins/data-inspector.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ Package: `@devframes/plugin-data-inspector` · framework: **Vue + Vite**
1010

1111
## What it does
1212

13-
- **Query workbench** — a CodeMirror jora editor with syntax highlighting and server-computed autocomplete; queries auto-run as you type, with a client-side syntax gate so malformed input never hits the wire. Source, query, and filters persist in the URL, so any workbench state is shareable.
13+
- **Query workbench** — a CodeMirror jora editor with syntax highlighting and server-computed autocomplete; queries auto-run as you type, with a client-side syntax gate so malformed input never hits the wire. A toolbar copies the query, and the editor pairs with expand-all / collapse-all and copy-as-JSON controls over the results. Source, query, filters, and the auto-rerun setting persist in the URL, so any workbench state is shareable.
14+
- **Auto rerun** — an optional poller under the filters (`auto rerun every N seconds`) re-runs the current query against the live object on a fixed period, so a value that changes over time updates on its own. Ticks are skipped while a run is in flight or the query is syntactically broken.
1415
- **Result viewer** — results normalize to strict JSON (circulars become `$ref` markers; Maps, Sets, class instances, functions, and Dates get type badges) with per-query stats: jora / normalize / rpc timings, payload size, node count. The value-actions popup copies paths and turns any key into a query.
16+
- **Lazy expansion** — deep graphs return one level at a time: a node past the depth cap renders a `load deeper` link that fetches just that subtree with a fresh budget and splices it in place, so a huge object stays responsive and loads on demand.
1517
- **Data shape panel** — a one-level type skeleton of the active source, independent of the query; click a property to query it.
1618
- **Filters** — exclude functions, `_`-prefixed, or `$`-prefixed properties from results and skeleton alike.
1719
- **Saved queries** — recipes (`query` + optional title/description + the filters they were authored with), id-keyed, in two scopes: **workspace** (committable, shared with the team) and **project** (per-checkout).
@@ -114,10 +116,14 @@ The target process opts in by starting the agent:
114116
```ts
115117
import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject'
116118

117-
await exposeDataInspector()
119+
await exposeDataInspector({
120+
sources: [{ id: 'app:store', title: 'App store', data: () => store }],
121+
})
118122
```
119123

120-
or with zero code changes:
124+
`sources` registers the given entries before the endpoint opens — a convenience over calling `registerDataSource` yourself; both paths share the one process-global registry. Call `exposeDataInspector()` with no sources to expose whatever is already registered.
125+
126+
Or with zero code changes:
121127

122128
```sh
123129
DEVFRAME_DATA_INSPECTOR=1 node --import @devframes/plugin-data-inspector/inject server.js
@@ -133,6 +139,7 @@ All functions are namespaced `devframes:plugin:data-inspector:*`:
133139
|----------|------|---------|
134140
| `sources` | `query` | Every registered source (meta and suggested queries). |
135141
| `query` | `query` | Runs a jora query against a source; normalized result with stats. |
142+
| `queryPath` | `query` | Re-runs a query and returns a fresh, depth-limited slice of the subtree at a node path (lazy expansion). |
136143
| `skeleton` | `query` | The type skeleton of a source, honoring the filter options. |
137144
| `suggest` | `query` | Autocomplete candidates from jora's stat mode at a cursor position. |
138145
| `saved:list` / `saved:save` / `saved:delete` | `query` / `action` | Saved-query recipes in the `workspace` and `project` scopes. |

plugins/data-inspector/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @devframes/plugin-data-inspector
22

3-
Inspect live server-side objects interactively. Other plugins and hosts register **data sources**; the workbench composes [jora](https://github.com/discoveryjs/jora) queries against them — executed in the process that owns the objects — and renders normalized results in a [discovery.js](https://github.com/discoveryjs/discovery) struct view with type badges, a shape panel, saved queries, and shareable URL state.
3+
Inspect live server-side objects interactively. Other plugins and hosts register **data sources**; the workbench composes [jora](https://github.com/discoveryjs/jora) queries against them — executed in the process that owns the objects — and renders normalized results in a [discovery.js](https://github.com/discoveryjs/discovery) struct view with type badges, a shape panel, saved queries, and shareable URL state. Deep graphs expand a level at a time (`load deeper` fetches each subtree on demand), an optional poller re-runs the query every N seconds, and a toolbar offers expand/collapse-all and copy.
44

55
## Register a data source
66

@@ -50,7 +50,10 @@ Static exports embed the dataset and run the same query engine client-side, so s
5050
```ts
5151
import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject'
5252

53-
await exposeDataInspector()
53+
// pass sources inline, or register them separately beforehand
54+
await exposeDataInspector({
55+
sources: [{ id: 'app:store', title: 'App store', data: () => store }],
56+
})
5457
```
5558

5659
or with zero code changes:

plugins/data-inspector/src/engine/contract.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ export interface FilterOptions {
1010
excludeDollarProps?: boolean
1111
}
1212

13+
/**
14+
* One structural step from a query result toward a nested node, recorded by
15+
* the normalizer on each depth-truncated marker so the client can lazily
16+
* re-fetch that subtree with a fresh depth budget (see `queryPath`). Each
17+
* segment mirrors exactly one descent the normalizer makes:
18+
*
19+
* - `['k', key]` own object property, or a string-keyed Map value
20+
* - `['i', index]` array item (index into the filtered array)
21+
* - `['s', index]` Set value
22+
* - `['mk', index]` / `['mv', index]` non-string Map entry key / value
23+
*/
24+
export type PathSegment
25+
= | ['k', string]
26+
| ['i', number]
27+
| ['s', number]
28+
| ['mk', number]
29+
| ['mv', number]
30+
31+
/** A path from the query root to a nested node: a list of structural steps. */
32+
export type NodePath = PathSegment[]
33+
1334
/** A query recipe: the text plus the filter options it was authored with. */
1435
export interface Query extends FilterOptions {
1536
query: string

plugins/data-inspector/src/engine/normalize.ts

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@
1212
* - BigInt / Symbol -> tagged string forms
1313
* - Error -> { $type: 'Error', name, message }
1414
* - Promise / WeakMap/.. -> opaque tags
15-
* - depth / entry caps -> { $truncated: ... } markers + stats
15+
* - depth cap -> { $truncated: 'depth', $preview, $path } markers
16+
* - entry cap -> { $truncated: 'entries', ... } markers + stats
17+
*
18+
* Depth-truncated markers carry a `$path` (a `NodePath` of structural steps
19+
* from the query root) so the client can lazily re-fetch that subtree with a
20+
* fresh depth budget through `navigate` + `runQueryAtPath`.
1621
*/
22+
import type { NodePath, PathSegment } from './contract'
1723

1824
export interface NormalizeOptions {
1925
/** Max object/array nesting depth before truncation. */
@@ -79,12 +85,47 @@ export function normalize(value: unknown, options: NormalizeOptions = {}): { dat
7985
excludeDollarProps: options.excludeDollarProps ?? false,
8086
},
8187
}
82-
const data = walk(value, walker, 0, '#')
88+
const data = walk(value, walker, 0, '#', [])
8389
walker.stats.ms = Math.round((performance.now() - start) * 100) / 100
8490
return { data, stats: walker.stats }
8591
}
8692

87-
function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
93+
/**
94+
* Re-descend a live graph along a `NodePath`, mirroring the walker's own
95+
* traversal (and re-applying `excludeFunctions` to array indices so a lazily
96+
* fetched index lines up with what the client saw). Returns the node the
97+
* path addresses, or `undefined` if any step falls off the graph.
98+
*/
99+
export function navigate(value: unknown, path: NodePath, options: Pick<NormalizeOptions, 'excludeFunctions'> = {}): unknown {
100+
let cur: unknown = value
101+
for (const [kind, at] of path) {
102+
if (cur === null || typeof cur !== 'object')
103+
return undefined
104+
switch (kind) {
105+
case 'k':
106+
cur = cur instanceof Map ? cur.get(at) : (cur as Record<string, unknown>)[at]
107+
break
108+
case 'i': {
109+
const arr = cur as unknown[]
110+
const source = options.excludeFunctions ? arr.filter(item => typeof item !== 'function') : arr
111+
cur = source[at]
112+
break
113+
}
114+
case 's':
115+
cur = [...(cur as Set<unknown>)][at]
116+
break
117+
case 'mk':
118+
cur = [...(cur as Map<unknown, unknown>).entries()][at]?.[0]
119+
break
120+
case 'mv':
121+
cur = [...(cur as Map<unknown, unknown>).entries()][at]?.[1]
122+
break
123+
}
124+
}
125+
return cur
126+
}
127+
128+
function walk(value: unknown, w: Walker, depth: number, path: string, segs: NodePath): unknown {
88129
w.stats.nodes++
89130

90131
// ── primitives ──────────────────────────────────────────────────────
@@ -138,7 +179,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
138179

139180
if (depth >= w.opts.maxDepth) {
140181
w.stats.truncatedDepth++
141-
return { $truncated: 'depth', $preview: preview(obj) }
182+
return { $truncated: 'depth', $preview: preview(obj), $path: segs }
142183
}
143184

144185
w.seen.set(obj, path)
@@ -148,7 +189,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
148189
const cap = Math.min(source.length, w.opts.maxEntries)
149190
const out: unknown[] = Array.from({ length: cap })
150191
for (let i = 0; i < cap; i++)
151-
out[i] = walk(source[i], w, depth + 1, `${path}[${i}]`)
192+
out[i] = walk(source[i], w, depth + 1, `${path}[${i}]`, seg(segs, ['i', i]))
152193
if (source.length > cap) {
153194
w.stats.truncatedEntries++
154195
out.push({ $truncated: 'entries', $total: source.length, $shown: cap })
@@ -169,15 +210,15 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
169210
if (allStringKeys) {
170211
const value: Record<string, unknown> = {}
171212
for (const [k, v] of entries)
172-
value[k as string] = walk(v, w, depth + 1, `${path}.${String(k)}`)
213+
value[k as string] = walk(v, w, depth + 1, `${path}.${String(k)}`, seg(segs, ['k', k as string]))
173214
return { $type: 'Map', size: obj.size, value }
174215
}
175216
return {
176217
$type: 'Map',
177218
size: obj.size,
178219
entries: entries.map(([k, v], i) => ({
179-
key: walk(k, w, depth + 1, `${path}~keys[${i}]`),
180-
value: walk(v, w, depth + 1, `${path}~values[${i}]`),
220+
key: walk(k, w, depth + 1, `${path}~keys[${i}]`, seg(segs, ['mk', i])),
221+
value: walk(v, w, depth + 1, `${path}~values[${i}]`, seg(segs, ['mv', i])),
181222
})),
182223
}
183224
}
@@ -186,7 +227,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
186227
const values = [...obj].slice(0, w.opts.maxEntries)
187228
if (obj.size > values.length)
188229
w.stats.truncatedEntries++
189-
return { $type: 'Set', size: obj.size, values: values.map((v, i) => walk(v, w, depth + 1, `${path}~set[${i}]`)) }
230+
return { $type: 'Set', size: obj.size, values: values.map((v, i) => walk(v, w, depth + 1, `${path}~set[${i}]`, seg(segs, ['s', i]))) }
190231
}
191232

192233
// Plain object or class instance: own enumerable string-keyed props.
@@ -213,7 +254,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
213254
}
214255
if (w.opts.excludeFunctions && typeof v === 'function')
215256
continue
216-
out[key] = walk(v, w, depth + 1, `${path}.${key}`)
257+
out[key] = walk(v, w, depth + 1, `${path}.${key}`, seg(segs, ['k', key]))
217258
}
218259
if (keys.length > cap) {
219260
w.stats.truncatedProps++
@@ -222,6 +263,11 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
222263
return out
223264
}
224265

266+
/** Append one structural step to a path, returning a fresh array. */
267+
function seg(path: NodePath, step: PathSegment): NodePath {
268+
return [...path, step]
269+
}
270+
225271
function preview(obj: object): string {
226272
if (Array.isArray(obj))
227273
return `Array(${obj.length})`

plugins/data-inspector/src/engine/query-engine.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
* - suggestions come from jora's stat mode, flattened into plain
1111
* RPC-safe completion items.
1212
*/
13-
import type { QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
13+
import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
1414
import type { NormalizeOptions } from './normalize'
1515
import jora from 'jora'
16-
import { normalize } from './normalize'
16+
import { navigate, normalize } from './normalize'
1717

1818
export type { SuggestItem, SuggestOutcome } from './contract'
1919

@@ -103,6 +103,29 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti
103103
}
104104
}
105105

106+
/**
107+
* Lazy-expand a depth-truncated node: re-run the base query against the live
108+
* object, re-descend to the node the `NodePath` addresses, and normalize just
109+
* that subtree with a fresh depth budget. The path comes from a `$truncated:
110+
* 'depth'` marker the client is expanding, so the same filter options must be
111+
* threaded through (they shift array indices and drop keys).
112+
*/
113+
export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome {
114+
try {
115+
const started = performance.now()
116+
const raw = createQuery(query)(target)
117+
const node = navigate(raw, path, options)
118+
const queryMs = Math.round((performance.now() - started) * 100) / 100
119+
const { data, stats } = normalize(node, options)
120+
const payloadBytes = new TextEncoder().encode(JSON.stringify(data) ?? '').length
121+
return { ok: true, result: data, stats: { queryMs, normalize: stats, payloadBytes } }
122+
}
123+
catch (error) {
124+
const e = error instanceof Error ? error : new Error(String(error))
125+
return { ok: false, error: { message: e.message, name: e.name } }
126+
}
127+
}
128+
106129
interface JoraStatEntry {
107130
type: string
108131
from: number

plugins/data-inspector/src/inject/index.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
* Two ways in:
66
*
77
* ```ts
8-
* // 1. explicit, from the target's code
8+
* // 1. explicit, from the target's code — pass sources inline …
99
* import { exposeDataInspector } from '@devframes/plugin-data-inspector/inject'
10-
* import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
1110
*
11+
* await exposeDataInspector({
12+
* sources: [{ id: 'app:store', title: 'App store', data: () => store }],
13+
* })
14+
*
15+
* // … or register them separately through the global registry.
16+
* import { registerDataSource } from '@devframes/plugin-data-inspector/registry'
1217
* registerDataSource({ id: 'app:store', title: 'App store', data: () => store })
1318
* await exposeDataInspector()
1419
* ```
@@ -27,6 +32,7 @@
2732
* treat the endpoint like a debugger port.
2833
*/
2934
import type { DevframeHost, DevframeNodeContext } from 'devframe/types'
35+
import type { DataSourceEntry } from '../registry/index'
3036
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
3137
import { homedir } from 'node:os'
3238
import { dirname, join } from 'node:path'
@@ -49,6 +55,14 @@ export interface AgentDiscovery {
4955
}
5056

5157
export interface ExposeDataInspectorOptions {
58+
/**
59+
* Data sources to expose, registered before the endpoint opens. A
60+
* convenience over calling `registerDataSource` yourself; the two paths
61+
* share one process-global registry, so inline sources and separately
62+
* registered ones coexist (a later registration replaces an earlier one
63+
* with the same id).
64+
*/
65+
sources?: DataSourceEntry[]
5266
/** Preferred port (falls back to a free one nearby). Default 9878. */
5367
port?: number
5468
/**
@@ -83,6 +97,12 @@ export async function exposeDataInspector(options: ExposeDataInspectorOptions =
8397
// into processes that don't enable it.
8498
const { setupDataInspector } = await import('../node/index')
8599

100+
if (options.sources?.length) {
101+
const { registerDataSource } = await import('../registry/index')
102+
for (const source of options.sources)
103+
registerDataSource(source)
104+
}
105+
86106
const cwd = process.cwd()
87107
const port = await getPort({ port: options.port ?? 9878, portRange: [9878, 9978] })
88108
const websocket = `ws://127.0.0.1:${port}`
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { FilterOptions, NodePath, QueryOutcome } from '../../engine/contract'
2+
import { runQueryAtPath } from '../../engine/query-engine'
3+
import { getDataSource, resolveSourceData } from '../../registry/index'
4+
import { defineDataInspectorRpc, NS } from './_define'
5+
6+
/**
7+
* Lazily expand a depth-truncated node. Re-runs the base jora query against
8+
* the live source, re-descends to the node addressed by `path` (a `NodePath`
9+
* lifted from the `$truncated: 'depth'` marker the client is expanding), and
10+
* returns just that subtree normalized with a fresh depth budget — so huge
11+
* graphs load a level at a time instead of all at once.
12+
*/
13+
export const queryPath = defineDataInspectorRpc({
14+
name: `${NS}:queryPath`,
15+
type: 'query',
16+
jsonSerializable: true,
17+
agent: {
18+
title: 'Expand a nested node',
19+
description: 'Re-run a jora query and return a fresh, depth-limited slice of the subtree at a given node path.',
20+
},
21+
setup: () => ({
22+
handler: async (
23+
sourceId: string,
24+
joraQuery: string,
25+
path: NodePath,
26+
options?: { maxDepth?: number, maxEntries?: number } & FilterOptions,
27+
): Promise<QueryOutcome> => {
28+
const source = getDataSource(sourceId)
29+
if (!source)
30+
return { ok: false, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } }
31+
return runQueryAtPath(await resolveSourceData(source), joraQuery, path, options)
32+
},
33+
}),
34+
})

plugins/data-inspector/src/rpc/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
22
import { query } from './functions/query'
3+
import { queryPath } from './functions/query-path'
34
import { savedDelete, savedList, savedSave } from './functions/saved'
45
import { skeleton } from './functions/skeleton'
56
import { sources } from './functions/sources'
@@ -12,6 +13,7 @@ import { suggest } from './functions/suggest'
1213
export const serverFunctions = [
1314
sources,
1415
query,
16+
queryPath,
1517
skeleton,
1618
suggest,
1719
savedList,

plugins/data-inspector/src/spa/App.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved
8282
:stats-stale="wb.statsStale.value"
8383
:error="wb.serverError.value"
8484
:running="wb.running.value"
85+
:expand="wb.expandNode"
8586
@rerun="wb.runNow()"
8687
@query-subquery="wb.applySubquery($event)"
8788
@query-append="wb.appendPath($event)"

0 commit comments

Comments
 (0)