Skip to content

Commit 7f4e665

Browse files
committed
feat(prototype): data-inspector rework — Vue/@antfu/design workbench, source contract, saved queries
Renames the spike to data-inspector and reworks it along the agreed direction: - data sources follow { id, title, description?, getData(), static? } with dynamic register/unregister + change listeners; static sources memoize getData() - saved queries (title/description, id-keyed) persisted per scope: user -> node_modules/.data-inspector, project -> .devframe/data-inspector (committable, shared with the team) - SPA rebuilt as Vue 3 + @antfu/design on the canonical uno preset: split-pane workbench, debounced auto-run, client-side jora syntax gate, non-destructive errors (last good result stays), stats bar with query time + payload size, remote suggestions, synced dark mode Full e2e verified in-browser; spike still 13/13. Findings captured in the prototype README.
1 parent e5d70e5 commit 7f4e665

37 files changed

Lines changed: 1442 additions & 672 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"queries": {
3+
"plugin-names": {
4+
"id": "plugin-names",
5+
"title": "Plugin names",
6+
"description": "All Vite plugin names in order",
7+
"query": "config.plugins.name",
8+
"sourceId": "vite:server",
9+
"updatedAt": 1784177708176
10+
}
11+
}
12+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# prototype-data-inspector
2+
3+
> **PROTOTYPE — throwaway code.** This is not an example and not a plugin.
4+
> It exists to answer one question and is kept on its spike branch as a
5+
> primary source. Do not merge, extend, or depend on it.
6+
7+
## The question
8+
9+
Can a **data-inspector plugin** work on devframe? Architecture under test:
10+
other plugins/hosts register live server objects into a registry; the UI
11+
composes jora queries that execute **server-side against the live object**;
12+
normalized results return over devframe RPC and render in a **themed
13+
discovery `struct` view** inside a **Vue + `@antfu/design`** workbench, with
14+
saved queries persisted per user or per project.
15+
16+
## Verdict: YES — validated end to end
17+
18+
Stage 1 (`pnpm --filter prototype-data-inspector spike`): **13/13 checks**
19+
against a real programmatic `ViteDevServer` (jora over live objects, Map/Set
20+
bridge methods, normalizer, stat-mode suggestions, function-invocation hazard).
21+
22+
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.
27+
28+
## The pieces (what the real plugin reuses)
29+
30+
- **`src/registry.ts`** — data sources as
31+
`{ id, title, description?, getData(): any, static?: boolean }`, registered
32+
dynamically against the shared `DevframeNodeContext`
33+
(`WeakMap` idiom from `plugins/git`). `static: true` memoizes `getData()`;
34+
`registerDataSource` returns an unregister fn and fires change listeners.
35+
- **`src/normalize.ts`** — the core asset: live graph -> strict JSON
36+
(circular -> `$ref`, Map/Set/class/function/BigInt/Error tagging,
37+
depth/entry/prop/string caps) with stats.
38+
- **`src/query-engine.ts`** — jora with duck-typed `fromMap()` /
39+
`mapEntries()` / `fromSet()` / `ownKeys()` / `typeOf()` bridges, plus
40+
flattened stat-mode suggestions.
41+
- **`src/saved-queries.ts`** — id-keyed saved queries via devframe
42+
`createStorage` (debounced atomic JSON):
43+
`user` -> `node_modules/.data-inspector/queries.json` (per checkout),
44+
`project` -> `.devframe/data-inspector/queries.json` (committable, shared).
45+
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`.
48+
- **`src/spa/`** — Vue 3 + `@antfu/design` workbench: canonical uno preset
49+
block (mirrors `plugins/inspect`), design components used directly
50+
(`LayoutToolbar`, `LayoutSplitPane`, `FormSelect`, `FormTextInput`,
51+
`ActionButton`/`ActionIconButton`/`ActionDarkToggle`,
52+
`DisplayBadge`/`DisplayBytes`/`DisplayDuration`), discovery `ViewModel`
53+
bound to a container ref with the default page redefined as a struct view.
54+
55+
## Findings (things the real plugin must know)
56+
57+
1. **Vite 8 compat surfaces**: `server.moduleGraph.idToModuleMap` is
58+
Map-*shaped* but not a `Map` (duck-type it), and compat `ModuleNode`s are
59+
getter facades whose own keys are `_moduleGraph`/`_clientModule`/`_ssrModule`
60+
only — query `environments.<name>.moduleGraph` for real own fields.
61+
2. **discovery under Vite** needs `define: { global: 'globalThis' }` and its
62+
CSS injected into the shadow root via `?inline` import. Render results
63+
through the model (`page.define('default', ...)` + `setData`) — rendering
64+
into `dom.pageContent` manually races discovery's own page cycle.
65+
3. **Theming**: `host.colorScheme.set()` + `--discovery-*` custom props
66+
(bridged to design tokens through CSS vars that flip with `.dark`).
67+
4. **jora syntax check runs client-side** (`jora.syntax.parse`, ~20 KB gzip):
68+
parse errors carry `details.loc.range`, so end-of-input errors show as a
69+
soft "keep typing" state and malformed queries never hit the wire.
70+
5. **Suggestions** arrive nested per stat entry (flatten server-side);
71+
prefix-filtering is the client's job.
72+
6. **`DisplayBadge` with an explicit CSS color** sets fg and bg to the same
73+
value — pass a hue number (theme-aware tint) or use `variant="solid"`.
74+
7. **`LayoutSplitPane`** needs `splitpanes` (+ `Pane`) as a direct dep — it's
75+
an `@antfu/design` peer, not a bundled dep.
76+
8. **HAZARD (by design of live-query mode)**: a jora query can invoke any
77+
function reachable as an own property of the live object and fires own
78+
getters; no CPU/timeout limits. Present it as eval-grade access to the dev
79+
server (localhost-only), and pin `jora >= 1.0.0-beta.16`.
80+
9. **Versions pinned deliberately**: `jora@1.0.0-beta.16`,
81+
`@discoveryjs/discovery@1.0.0-beta.99` — both beta with breaking renames
82+
between betas.
83+
84+
## Deviations from repo conventions (prototype-only)
85+
86+
- `jora` + `@discoveryjs/discovery` pinned in the *default* catalog (pnpm
87+
auto-promoted them); a real plugin would place them in a named catalog
88+
- plain textarea editor; production should embed discovery's `QueryEditor`
89+
(CodeMirror 5 + jora mode) with the async-suggestion re-trigger pattern
90+
- `.vue` internals aren't typechecked (`tsc --noEmit` + shims, same as the
91+
existing plugins)
92+
93+
## Run it
94+
95+
```sh
96+
pnpm --filter prototype-data-inspector spike # stage 1: node-only proof, prints 13 checks
97+
pnpm --filter prototype-data-inspector dev # stage 2: http://localhost:5173/
98+
```
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Data Inspector — PROTOTYPE</title>
7+
</head>
8+
<body>
9+
<div id="app"></div>
10+
<script type="module" src="/src/spa/main.ts"></script>
11+
</body>
12+
</html>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "prototype-data-inspector",
3+
"type": "module",
4+
"version": "0.0.0",
5+
"private": true,
6+
"description": "PROTOTYPE (throwaway) — validates the data-inspector plugin architecture: server-side jora over live registered objects + RPC + saved queries + a Vue/@antfu/design workbench with a themed discovery struct view. Not a real example; see README.",
7+
"scripts": {
8+
"dev": "vite --host",
9+
"spike": "tsx src/spike.ts",
10+
"typecheck": "tsc --noEmit"
11+
},
12+
"dependencies": {
13+
"@antfu/design": "catalog:frontend",
14+
"@discoveryjs/discovery": "catalog:",
15+
"devframe": "workspace:*",
16+
"floating-vue": "catalog:frontend",
17+
"jora": "catalog:",
18+
"reka-ui": "catalog:frontend",
19+
"splitpanes": "catalog:",
20+
"vue": "catalog:frontend"
21+
},
22+
"devDependencies": {
23+
"@iconify-json/ph": "catalog:frontend",
24+
"@vitejs/plugin-vue": "catalog:build",
25+
"get-port-please": "catalog:deps",
26+
"tsx": "catalog:build",
27+
"unocss": "catalog:frontend",
28+
"vite": "catalog:build"
29+
}
30+
}
File renamed without changes.

examples/prototype-data-viewer/src/env.d.ts renamed to examples/prototype-data-inspector/src/env.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ declare module 'jora' {
1111
assertions?: Record<string, string | ((current: unknown) => boolean)>
1212
}
1313
export type JoraQueryFn = (data: unknown, context?: unknown) => unknown
14+
export interface JoraSyntax {
15+
parse: (source: string, tolerantMode?: boolean) => unknown
16+
tokenize: (source: string) => unknown
17+
stringify: (ast: unknown) => string
18+
walk: (ast: unknown, visitor: unknown) => void
19+
}
1420
export interface Jora {
1521
(query: string, options?: JoraQueryOptions): JoraQueryFn
1622
setup: (options?: JoraSetupOptions) => (query: string, options?: JoraQueryOptions) => JoraQueryFn
23+
syntax: JoraSyntax
1724
version: string
1825
}
1926
const jora: Jora
File renamed without changes.

examples/prototype-data-viewer/src/query-engine.ts renamed to examples/prototype-data-inspector/src/query-engine.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* suggestions. Custom methods bridge jora's blind spots on live graphs
66
* (Map/Set are opaque to it; the prototype chain is not walked).
77
*/
8-
import type { NormalizeOptions, NormalizeStats } from './normalize'
9-
import type { SuggestItem, SuggestOutcome } from './rpc-contract'
8+
import type { NormalizeOptions } from './normalize'
9+
import type { QueryOutcome, SuggestItem, SuggestOutcome } from './rpc-contract'
1010
import jora from 'jora'
1111
import { normalize } from './normalize'
1212

@@ -50,24 +50,17 @@ const createQuery = jora.setup({
5050
},
5151
})
5252

53-
export interface QuerySuccess {
54-
ok: true
55-
result: unknown
56-
stats: { queryMs: number, normalize: NormalizeStats }
57-
}
58-
export interface QueryFailure {
59-
ok: false
60-
error: { message: string, name: string }
61-
}
62-
export type QueryOutcome = QuerySuccess | QueryFailure
53+
export type { QueryOutcome } from './rpc-contract'
6354

6455
export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome {
6556
try {
6657
const started = performance.now()
6758
const raw = createQuery(query)(target)
6859
const queryMs = Math.round((performance.now() - started) * 100) / 100
6960
const { data, stats } = normalize(raw, options)
70-
return { ok: true, result: data, stats: { queryMs, normalize: stats } }
61+
// The normalizer guarantees plain JSON, so this measures the actual wire payload.
62+
const payloadBytes = new TextEncoder().encode(JSON.stringify(data) ?? '').length
63+
return { ok: true, result: data, stats: { queryMs, normalize: stats, payloadBytes } }
7164
}
7265
catch (error) {
7366
const e = error instanceof Error ? error : new Error(String(error))
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* PROTOTYPE — throwaway code.
3+
*
4+
* The data-source registry under validation: a `WeakMap` keyed by the shared
5+
* `DevframeNodeContext` (the `plugins/git/src/rpc/context.ts` idiom). Every
6+
* plugin's `setup()` and the host's `configureServer` receive the SAME context
7+
* object, so anyone can register sources dynamically — at setup time or later —
8+
* and the data-inspector finds them with no dependency edge beyond this module.
9+
*/
10+
import type { DataSourceMeta } from './rpc-contract'
11+
12+
export interface DataSourceEntry {
13+
id: string
14+
title: string
15+
description?: string
16+
/** Produce the object to query. Called per query unless `static`. */
17+
getData: () => unknown
18+
/** Data never changes; `getData()` is called once and memoized (default false). */
19+
static?: boolean
20+
}
21+
22+
interface Registry {
23+
entries: Map<string, DataSourceEntry>
24+
/** Memoized `getData()` results for `static: true` sources. */
25+
staticCache: Map<string, unknown>
26+
listeners: Set<() => void>
27+
}
28+
29+
const registries = new WeakMap<object, Registry>()
30+
31+
function registryFor(ctx: object): Registry {
32+
let registry = registries.get(ctx)
33+
if (!registry) {
34+
registry = { entries: new Map(), staticCache: new Map(), listeners: new Set() }
35+
registries.set(ctx, registry)
36+
}
37+
return registry
38+
}
39+
40+
function notify(registry: Registry): void {
41+
for (const listener of registry.listeners)
42+
listener()
43+
}
44+
45+
/** Register (or replace) a source. Returns an unregister function. */
46+
export function registerDataSource(ctx: object, entry: DataSourceEntry): () => void {
47+
const registry = registryFor(ctx)
48+
registry.entries.set(entry.id, entry)
49+
registry.staticCache.delete(entry.id)
50+
notify(registry)
51+
return () => unregisterDataSource(ctx, entry.id)
52+
}
53+
54+
export function unregisterDataSource(ctx: object, id: string): void {
55+
const registry = registryFor(ctx)
56+
if (registry.entries.delete(id)) {
57+
registry.staticCache.delete(id)
58+
notify(registry)
59+
}
60+
}
61+
62+
export function listDataSources(ctx: object): DataSourceMeta[] {
63+
return Array.from(registryFor(ctx).entries.values()).map(entry => ({
64+
id: entry.id,
65+
title: entry.title,
66+
description: entry.description,
67+
static: entry.static ?? false,
68+
}))
69+
}
70+
71+
export function getDataSource(ctx: object, id: string): DataSourceEntry | undefined {
72+
return registryFor(ctx).entries.get(id)
73+
}
74+
75+
/** Resolve a source's data, honoring `static` memoization. */
76+
export function resolveSourceData(ctx: object, entry: DataSourceEntry): unknown {
77+
if (!entry.static)
78+
return entry.getData()
79+
const registry = registryFor(ctx)
80+
if (!registry.staticCache.has(entry.id))
81+
registry.staticCache.set(entry.id, entry.getData())
82+
return registry.staticCache.get(entry.id)
83+
}
84+
85+
/** Subscribe to registry changes (register/unregister). Returns unsubscribe. */
86+
export function onDataSourcesChanged(ctx: object, listener: () => void): () => void {
87+
const registry = registryFor(ctx)
88+
registry.listeners.add(listener)
89+
return () => registry.listeners.delete(listener)
90+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* PROTOTYPE — throwaway code.
3+
*
4+
* Wire types shared by the server RPC functions and the SPA. Types only —
5+
* safe to import from browser code without dragging jora into the bundle.
6+
*/
7+
8+
/** What the client sees of a registered data source. */
9+
export interface DataSourceMeta {
10+
id: string
11+
title: string
12+
description?: string
13+
/** Data never changes; the server memoizes `getData()`. */
14+
static: boolean
15+
}
16+
17+
/** One completion candidate: replace [from, to) with `value`. */
18+
export interface SuggestItem {
19+
type: string
20+
from: number
21+
to: number
22+
/** The fragment currently typed in that range. */
23+
current: string
24+
/** The completion to insert. */
25+
value: string
26+
}
27+
28+
export interface SuggestOutcome {
29+
ok: boolean
30+
suggestions: SuggestItem[]
31+
statMs: number
32+
error?: string
33+
}
34+
35+
export interface NormalizeStatsWire {
36+
nodes: number
37+
refs: number
38+
truncatedDepth: number
39+
truncatedEntries: number
40+
truncatedProps: number
41+
ms: number
42+
}
43+
44+
export interface QueryStats {
45+
queryMs: number
46+
normalize: NormalizeStatsWire
47+
payloadBytes: number
48+
}
49+
50+
export type QueryOutcome
51+
= | { ok: true, result: unknown, stats: QueryStats }
52+
| { ok: false, error: { name: string, message: string } }
53+
54+
/** Where a saved query persists. */
55+
export type SavedQueryScope = 'user' | 'project'
56+
57+
export interface SavedQuery {
58+
/** Storage key. Derived from the title when not supplied. */
59+
id: string
60+
title: string
61+
description?: string
62+
query: string
63+
/** Source the query was authored against. */
64+
sourceId: string
65+
scope: SavedQueryScope
66+
updatedAt: number
67+
}
68+
69+
export interface SaveQueryInput {
70+
id?: string
71+
title: string
72+
description?: string
73+
query: string
74+
sourceId: string
75+
scope: SavedQueryScope
76+
}

0 commit comments

Comments
 (0)