Skip to content

Commit 36941c1

Browse files
committed
feat(prototype): skeleton panel, query settings, root view, rerun, flush padding
- empty query now runs $ so every source lands on its entire object - query settings (ignore functions / _-prefixed / $-prefixed keys), persisted client-side and applied to both results and the skeleton - 'available data' panel: query-independent type skeleton of the active source (ancestor-path cycle detection keeps shared refs expanded) - manual re-run button in the stats bar - left column sections (editor, skeleton, saved queries) resize via the same @antfu/design LayoutSplitPane setup as the outer split - discovery page padding zeroed via --discovery-page-padding-*
1 parent 7f4e665 commit 36941c1

11 files changed

Lines changed: 433 additions & 52 deletions

File tree

examples/prototype-data-inspector/README.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ against a real programmatic `ViteDevServer` (jora over live objects, Map/Set
2020
bridge methods, normalizer, stat-mode suggestions, function-invocation hazard).
2121

2222
Stage 2 (`pnpm --filter prototype-data-inspector dev`): the full workbench
23-
against the Vite server serving the page itself — left editor / right viewer
24-
split panes, debounced auto-run, client-side syntax gate, non-destructive
25-
errors, per-query stats (jora / normalize / rpc timings, payload size, node
26-
count), remote autocomplete, saved queries in both scopes, synced dark mode.
23+
against the Vite server serving the page itself — resizable split panes
24+
(`LayoutSplitPane`, outer editor|viewer plus the stacked left column),
25+
debounced auto-run with a manual re-run button, an empty query running `$`
26+
(the entire source object), client-side syntax gate, non-destructive errors,
27+
per-query stats (jora / normalize / rpc timings, payload size, node count),
28+
query settings (ignore functions / `_`-prefixed / `$`-prefixed, persisted,
29+
applied to results and skeleton alike), an "available data" panel showing the
30+
source's type skeleton independent of the query, remote autocomplete, saved
31+
queries in both scopes, and synced dark mode.
2732

2833
## The pieces (what the real plugin reuses)
2934

@@ -34,7 +39,11 @@ count), remote autocomplete, saved queries in both scopes, synced dark mode.
3439
`registerDataSource` returns an unregister fn and fires change listeners.
3540
- **`src/normalize.ts`** — the core asset: live graph -> strict JSON
3641
(circular -> `$ref`, Map/Set/class/function/BigInt/Error tagging,
37-
depth/entry/prop/string caps) with stats.
42+
depth/entry/prop/string caps) with stats, honoring the client's ignore
43+
settings (functions, `_`/`$`-prefixed keys).
44+
- **`src/skeleton.ts`** — "what data are available": walks a source into a
45+
compact type skeleton (keys + type names, ancestor-path cycle detection so
46+
shared refs still expand), same ignore settings.
3847
- **`src/query-engine.ts`** — jora with duck-typed `fromMap()` /
3948
`mapEntries()` / `fromSet()` / `ownKeys()` / `typeOf()` bridges, plus
4049
flattened stat-mode suggestions.
@@ -43,8 +52,8 @@ count), remote autocomplete, saved queries in both scopes, synced dark mode.
4352
`user` -> `node_modules/.data-inspector/queries.json` (per checkout),
4453
`project` -> `.devframe/data-inspector/queries.json` (committable, shared).
4554
Saving an id into one scope removes its twin from the other.
46-
- **`src/rpc-functions.ts`**`data-inspector:{sources,query,suggest}` +
47-
`data-inspector:saved:{list,save,delete}`, all `jsonSerializable`.
55+
- **`src/rpc-functions.ts`**`data-inspector:{sources,query,skeleton,suggest}`
56+
+ `data-inspector:saved:{list,save,delete}`, all `jsonSerializable`.
4857
- **`src/spa/`** — Vue 3 + `@antfu/design` workbench: canonical uno preset
4958
block (mirrors `plugins/inspect`), design components used directly
5059
(`LayoutToolbar`, `LayoutSplitPane`, `FormSelect`, `FormTextInput`,
@@ -63,7 +72,9 @@ count), remote autocomplete, saved queries in both scopes, synced dark mode.
6372
through the model (`page.define('default', ...)` + `setData`) — rendering
6473
into `dom.pageContent` manually races discovery's own page cycle.
6574
3. **Theming**: `host.colorScheme.set()` + `--discovery-*` custom props
66-
(bridged to design tokens through CSS vars that flip with `.dark`).
75+
(bridged to design tokens through CSS vars that flip with `.dark`);
76+
`--discovery-page-padding-{top,right,bottom,left}: 0` removes the stock
77+
page padding so the struct sits flush in the panel.
6778
4. **jora syntax check runs client-side** (`jora.syntax.parse`, ~20 KB gzip):
6879
parse errors carry `details.loc.range`, so end-of-input errors show as a
6980
soft "keep typing" state and malformed queries never hit the wire.

examples/prototype-data-inspector/src/normalize.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ export interface NormalizeOptions {
2626
maxProps?: number
2727
/** Max string length before truncation. */
2828
maxString?: number
29+
/** Drop function values (object props and array items). */
30+
ignoreFunctions?: boolean
31+
/** Drop object properties whose key starts with `_`. */
32+
ignoreUnderscorePrefixed?: boolean
33+
/** Drop object properties whose key starts with `$`. */
34+
ignoreDollarPrefixed?: boolean
35+
}
36+
37+
/** True when a property key is excluded by the ignore settings. */
38+
export function isIgnoredKey(key: string, opts: Pick<NormalizeOptions, 'ignoreUnderscorePrefixed' | 'ignoreDollarPrefixed'>): boolean {
39+
if (opts.ignoreUnderscorePrefixed && key.startsWith('_'))
40+
return true
41+
if (opts.ignoreDollarPrefixed && key.startsWith('$'))
42+
return true
43+
return false
2944
}
3045

3146
export interface NormalizeStats {
@@ -61,6 +76,9 @@ export function normalize(value: unknown, options: NormalizeOptions = {}): { dat
6176
maxEntries: options.maxEntries ?? 200,
6277
maxProps: options.maxProps ?? 150,
6378
maxString: options.maxString ?? 4000,
79+
ignoreFunctions: options.ignoreFunctions ?? false,
80+
ignoreUnderscorePrefixed: options.ignoreUnderscorePrefixed ?? false,
81+
ignoreDollarPrefixed: options.ignoreDollarPrefixed ?? false,
6482
},
6583
}
6684
const data = walk(value, walker, 0, '#')
@@ -128,13 +146,14 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
128146
w.seen.set(obj, path)
129147

130148
if (Array.isArray(obj)) {
131-
const cap = Math.min(obj.length, w.opts.maxEntries)
149+
const source = w.opts.ignoreFunctions ? obj.filter(item => typeof item !== 'function') : obj
150+
const cap = Math.min(source.length, w.opts.maxEntries)
132151
const out: unknown[] = Array.from({ length: cap })
133152
for (let i = 0; i < cap; i++)
134-
out[i] = walk(obj[i], w, depth + 1, `${path}[${i}]`)
135-
if (obj.length > cap) {
153+
out[i] = walk(source[i], w, depth + 1, `${path}[${i}]`)
154+
if (source.length > cap) {
136155
w.stats.truncatedEntries++
137-
out.push({ $truncated: 'entries', $total: obj.length, $shown: cap })
156+
out.push({ $truncated: 'entries', $total: source.length, $shown: cap })
138157
}
139158
return out
140159
}
@@ -182,7 +201,7 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
182201
if (className && className !== 'Object')
183202
out.$class = className
184203

185-
const keys = Object.keys(obj)
204+
const keys = Object.keys(obj).filter(key => !isIgnoredKey(key, w.opts))
186205
const cap = Math.min(keys.length, w.opts.maxProps)
187206
for (let i = 0; i < cap; i++) {
188207
const key = keys[i]
@@ -194,6 +213,8 @@ function walk(value: unknown, w: Walker, depth: number, path: string): unknown {
194213
out[key] = { $type: 'getter-error', message: error instanceof Error ? error.message : String(error) }
195214
continue
196215
}
216+
if (w.opts.ignoreFunctions && typeof v === 'function')
217+
continue
197218
out[key] = walk(v, w, depth + 1, `${path}.${key}`)
198219
}
199220
if (keys.length > cap) {

examples/prototype-data-inspector/src/rpc-contract.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ export type QueryOutcome
5151
= | { ok: true, result: unknown, stats: QueryStats }
5252
| { ok: false, error: { name: string, message: string } }
5353

54+
/** Client-controlled query settings, forwarded to the normalizer/skeleton. */
55+
export interface QuerySettings {
56+
ignoreFunctions?: boolean
57+
ignoreUnderscorePrefixed?: boolean
58+
ignoreDollarPrefixed?: boolean
59+
}
60+
61+
export type SkeletonOutcome
62+
= | { ok: true, skeleton: unknown, nodes: number, ms: number }
63+
| { ok: false, error: { name: string, message: string } }
64+
5465
/** Where a saved query persists. */
5566
export type SavedQueryScope = 'user' | 'project'
5667

examples/prototype-data-inspector/src/rpc-functions.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
* strict and proves wire-safety on every call.
88
*/
99
import type { DevframeNodeContext } from 'devframe/types'
10-
import type { SavedQueryScope, SaveQueryInput } from './rpc-contract'
10+
import type { QuerySettings, SavedQueryScope, SaveQueryInput, SkeletonOutcome } from './rpc-contract'
1111
import { createDefineWrapperWithContext } from 'devframe/rpc'
1212
import { runQuery, suggest } from './query-engine'
1313
import { getDataSource, listDataSources, resolveSourceData } from './registry'
1414
import { deleteSavedQuery, listSavedQueries, saveQuery } from './saved-queries'
15+
import { skeletonOf } from './skeleton'
1516

1617
const defineRpc = createDefineWrapperWithContext<DevframeNodeContext>()
1718

@@ -31,7 +32,7 @@ export const queryFn = defineRpc({
3132
type: 'query',
3233
jsonSerializable: true,
3334
setup: ctx => ({
34-
handler: async (sourceId: string, joraQuery: string, options?: { maxDepth?: number, maxEntries?: number }) => {
35+
handler: async (sourceId: string, joraQuery: string, options?: { maxDepth?: number, maxEntries?: number } & QuerySettings) => {
3536
const source = getDataSource(ctx, sourceId)
3637
if (!source)
3738
return { ok: false as const, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } }
@@ -40,6 +41,26 @@ export const queryFn = defineRpc({
4041
}),
4142
})
4243

44+
export const skeletonFn = defineRpc({
45+
name: `${NS}:skeleton`,
46+
type: 'query',
47+
jsonSerializable: true,
48+
setup: ctx => ({
49+
handler: async (sourceId: string, options?: QuerySettings): Promise<SkeletonOutcome> => {
50+
const source = getDataSource(ctx, sourceId)
51+
if (!source)
52+
return { ok: false, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } }
53+
try {
54+
return { ok: true, ...skeletonOf(resolveSourceData(ctx, source), options) }
55+
}
56+
catch (error) {
57+
const e = error instanceof Error ? error : new Error(String(error))
58+
return { ok: false, error: { name: e.name, message: e.message } }
59+
}
60+
},
61+
}),
62+
})
63+
4364
export const suggestFn = defineRpc({
4465
name: `${NS}:suggest`,
4566
type: 'query',
@@ -81,4 +102,4 @@ export const savedDeleteFn = defineRpc({
81102
}),
82103
})
83104

84-
export const allRpcFunctions = [sourcesFn, queryFn, suggestFn, savedListFn, savedSaveFn, savedDeleteFn]
105+
export const allRpcFunctions = [sourcesFn, queryFn, skeletonFn, suggestFn, savedListFn, savedSaveFn, savedDeleteFn]
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* PROTOTYPE — throwaway code.
3+
*
4+
* "What data are available": walks a live object into a compact type
5+
* SKELETON (keys and type names, no values) so users can see the shape of a
6+
* source while composing queries — independent of any query.
7+
*
8+
* - primitives -> their type name ('string', 'number', ...)
9+
* - functions -> 'function'
10+
* - arrays -> [skeleton of first item, '+N more']
11+
* - Map/Set (incl. -like)-> 'Map(size) { <key> => <value> }' expansions
12+
* - class instances -> own props + $class tag
13+
* - circular -> '[circular]'
14+
* - depth/prop caps -> '...'
15+
*/
16+
import type { NormalizeOptions } from './normalize'
17+
import { isIgnoredKey } from './normalize'
18+
19+
export type SkeletonOptions = Pick<
20+
NormalizeOptions,
21+
'maxDepth' | 'maxProps' | 'ignoreFunctions' | 'ignoreUnderscorePrefixed' | 'ignoreDollarPrefixed'
22+
>
23+
24+
interface SkeletonWalker {
25+
seen: WeakSet<object>
26+
nodes: number
27+
opts: Required<SkeletonOptions>
28+
}
29+
30+
export function skeletonOf(value: unknown, options: SkeletonOptions = {}): { skeleton: unknown, nodes: number, ms: number } {
31+
const started = performance.now()
32+
const walker: SkeletonWalker = {
33+
seen: new WeakSet(),
34+
nodes: 0,
35+
opts: {
36+
maxDepth: options.maxDepth ?? 5,
37+
maxProps: options.maxProps ?? 80,
38+
ignoreFunctions: options.ignoreFunctions ?? false,
39+
ignoreUnderscorePrefixed: options.ignoreUnderscorePrefixed ?? false,
40+
ignoreDollarPrefixed: options.ignoreDollarPrefixed ?? false,
41+
},
42+
}
43+
const skeleton = walk(value, walker, 0)
44+
return { skeleton, nodes: walker.nodes, ms: Math.round((performance.now() - started) * 100) / 100 }
45+
}
46+
47+
function isMapLike(v: object): v is Map<unknown, unknown> {
48+
return typeof (v as Map<unknown, unknown>).entries === 'function'
49+
&& typeof (v as Map<unknown, unknown>).get === 'function'
50+
&& typeof (v as Map<unknown, unknown>).size === 'number'
51+
}
52+
53+
function isSetLike(v: object): v is Set<unknown> {
54+
return typeof (v as Set<unknown>).has === 'function'
55+
&& typeof (v as Set<unknown>)[Symbol.iterator] === 'function'
56+
&& typeof (v as Map<unknown, unknown>).get !== 'function'
57+
&& typeof (v as Set<unknown>).size === 'number'
58+
}
59+
60+
function walk(value: unknown, w: SkeletonWalker, depth: number): unknown {
61+
w.nodes++
62+
if (value === null || value === undefined)
63+
return String(value)
64+
const t = typeof value
65+
if (t !== 'object')
66+
return t // 'string' | 'number' | 'boolean' | 'bigint' | 'symbol' | 'function'
67+
68+
const obj = value as object
69+
if (obj instanceof Date)
70+
return 'Date'
71+
if (obj instanceof RegExp)
72+
return 'RegExp'
73+
if (obj instanceof URL)
74+
return 'URL'
75+
if (obj instanceof Error)
76+
return 'Error'
77+
if (obj instanceof Promise)
78+
return 'Promise'
79+
80+
if (w.seen.has(obj))
81+
return '[circular]'
82+
if (depth >= w.opts.maxDepth)
83+
return '...'
84+
// Ancestor-path tracking (add/delete) so SHARED refs still expand and only
85+
// true cycles collapse to '[circular]'.
86+
w.seen.add(obj)
87+
try {
88+
return walkObject(obj, w, depth)
89+
}
90+
finally {
91+
w.seen.delete(obj)
92+
}
93+
}
94+
95+
function walkObject(obj: object, w: SkeletonWalker, depth: number): unknown {
96+
if (Array.isArray(obj)) {
97+
const items = w.opts.ignoreFunctions ? obj.filter(item => typeof item !== 'function') : obj
98+
if (items.length === 0)
99+
return []
100+
const first = walk(items[0], w, depth + 1)
101+
return items.length > 1 ? [first, `+${items.length - 1} more`] : [first]
102+
}
103+
104+
if (ArrayBuffer.isView(obj))
105+
return obj.constructor?.name ?? 'TypedArray'
106+
107+
if (isMapLike(obj)) {
108+
const first = obj.entries().next().value as [unknown, unknown] | undefined
109+
if (!first)
110+
return `Map(0)`
111+
return {
112+
[`Map(${obj.size})`]: {
113+
key: walk(first[0], w, depth + 1),
114+
value: walk(first[1], w, depth + 1),
115+
},
116+
}
117+
}
118+
119+
if (isSetLike(obj)) {
120+
const first = obj[Symbol.iterator]().next().value
121+
return first === undefined
122+
? `Set(0)`
123+
: { [`Set(${obj.size})`]: walk(first, w, depth + 1) }
124+
}
125+
126+
const proto = Object.getPrototypeOf(obj)
127+
const className = proto && proto !== Object.prototype ? (proto.constructor?.name as string | undefined) : undefined
128+
129+
const out: Record<string, unknown> = {}
130+
if (className && className !== 'Object')
131+
out.$class = className
132+
133+
const keys = Object.keys(obj).filter(key => !isIgnoredKey(key, w.opts))
134+
const cap = Math.min(keys.length, w.opts.maxProps)
135+
for (let i = 0; i < cap; i++) {
136+
const key = keys[i]
137+
let v: unknown
138+
try {
139+
v = (obj as Record<string, unknown>)[key]
140+
}
141+
catch {
142+
out[key] = 'getter-error'
143+
continue
144+
}
145+
if (w.opts.ignoreFunctions && typeof v === 'function')
146+
continue
147+
out[key] = walk(v, w, depth + 1)
148+
}
149+
if (keys.length > cap)
150+
out['...'] = `+${keys.length - cap} more props`
151+
return out
152+
}

0 commit comments

Comments
 (0)