From e5d70e515f0260a8de65cd6728f8125f30fc96cb Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 16 Jul 2026 04:07:47 +0000 Subject: [PATCH 01/11] chore: spike throwaway data-viewer prototype (jora live-query + discovery viewer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the live-query data-viewer architecture end to end: server-side jora over live registered objects (incl. the ViteDevServer serving the page), result normalizer, devframe RPC round-trip, remote stat-mode autocomplete, and a themed discovery struct view in devframe-style chrome. Stage-1 node spike passes 13/13 checks; findings and hazards documented in the prototype README. Throwaway code — lives on this spike branch as a primary source, not for merging into main. --- examples/prototype-data-viewer/README.md | 91 +++++++ examples/prototype-data-viewer/index.html | 142 +++++++++++ examples/prototype-data-viewer/package.json | 22 ++ .../prototype-data-viewer/src/client/main.ts | 224 ++++++++++++++++++ .../prototype-data-viewer/src/demo-data.ts | 64 +++++ examples/prototype-data-viewer/src/env.d.ts | 21 ++ .../prototype-data-viewer/src/normalize.ts | 216 +++++++++++++++++ .../prototype-data-viewer/src/query-engine.ts | 121 ++++++++++ .../prototype-data-viewer/src/registry.ts | 46 ++++ .../prototype-data-viewer/src/rpc-contract.ts | 24 ++ .../src/rpc-functions.ts | 55 +++++ examples/prototype-data-viewer/src/spike.ts | 224 ++++++++++++++++++ examples/prototype-data-viewer/tsconfig.json | 12 + examples/prototype-data-viewer/vite.config.ts | 107 +++++++++ pnpm-lock.yaml | 117 ++++++++- pnpm-workspace.yaml | 3 + 16 files changed, 1486 insertions(+), 3 deletions(-) create mode 100644 examples/prototype-data-viewer/README.md create mode 100644 examples/prototype-data-viewer/index.html create mode 100644 examples/prototype-data-viewer/package.json create mode 100644 examples/prototype-data-viewer/src/client/main.ts create mode 100644 examples/prototype-data-viewer/src/demo-data.ts create mode 100644 examples/prototype-data-viewer/src/env.d.ts create mode 100644 examples/prototype-data-viewer/src/normalize.ts create mode 100644 examples/prototype-data-viewer/src/query-engine.ts create mode 100644 examples/prototype-data-viewer/src/registry.ts create mode 100644 examples/prototype-data-viewer/src/rpc-contract.ts create mode 100644 examples/prototype-data-viewer/src/rpc-functions.ts create mode 100644 examples/prototype-data-viewer/src/spike.ts create mode 100644 examples/prototype-data-viewer/tsconfig.json create mode 100644 examples/prototype-data-viewer/vite.config.ts diff --git a/examples/prototype-data-viewer/README.md b/examples/prototype-data-viewer/README.md new file mode 100644 index 0000000..ef9507d --- /dev/null +++ b/examples/prototype-data-viewer/README.md @@ -0,0 +1,91 @@ +# prototype-data-viewer + +> **PROTOTYPE — throwaway code.** This is not an example and not a plugin. +> It exists to answer one question and is kept on its spike branch as a +> primary source. Do not merge, extend, or depend on it. + +## The question + +Can a **live-query data-viewer plugin** work on devframe? Architecture under +test (decided 2026-07-16): queries always execute **server-side** against +**live registered objects** (jora), results are normalized and returned over +devframe RPC, and the browser renders them with a **themed discovery `struct` +view** inside devframe-style chrome — with remote autocomplete from jora's +stat mode. No stock discovery explorer page, no client-side dataset. + +## Verdict: YES — architecture validated end to end + +Stage 1 (`pnpm --filter prototype-data-viewer spike`): **13/13 checks pass** +against a real programmatic `ViteDevServer`. + +Stage 2 (`pnpm --filter prototype-data-viewer dev`, then open the page): +full loop works in-browser against the Vite server serving the page itself — +e.g. this query over the live Vite 8 environment module graph: + +```jora +environments.client.moduleGraph.idToModuleMap.mapEntries().value.({ url, type, importers: importers.fromSet().url }) +``` + +renders 20 modules in discovery's struct view at `rpc 9ms · jora 0.63ms · +normalize 0.25ms · 101 nodes`, with server-side suggestions and synced +light/dark theming. + +## Findings (things the real plugin must know) + +1. **Registry pattern**: `WeakMap>` + (`src/registry.ts`) works exactly like `plugins/git/src/rpc/context.ts`. + The Vite host registers the live server in `configureServer` where both + the context and the `ViteDevServer` are in scope (`vite.config.ts`). +2. **jora on live objects needs bridging methods** (`src/query-engine.ts`): + Map/Set are opaque to raw jora, so ship `fromMap()` / `mapEntries()` / + `fromSet()` / `ownKeys()` / `typeOf()`. **Duck-type Map-likes**: Vite 8's + backward-compat `server.moduleGraph.idToModuleMap` is a Map-shaped plain + object, not a `Map` instance. +3. **Vite 8 compat `ModuleNode`s are getter facades** — own keys are only + `_moduleGraph`/`_clientModule`/`_ssrModule`; the public props live on the + prototype and are invisible to jora. Query + `environments..moduleGraph` instead (real own fields). +4. **The normalizer is the core asset** (`src/normalize.ts`): circular→`$ref`, + Map/Set/class/function/BigInt/Error tagging, depth/entry/prop/string caps. + Whole live `ResolvedConfig` → 873 nodes / 24 KB JSON in ~0.5 ms. With it, + results are strict-JSON (`jsonSerializable: true` RPC). +5. **Remote autocomplete works**: jora stat mode (`{ stat: true, tolerant: + true }`) runs server-side in ~0.2–0.5 ms; completions arrive nested in a + `suggestions` array per stat entry — flatten before shipping. Client + debounces and re-requests (`src/client/main.ts`). jora does NOT + prefix-filter; that's the client's job (this prototype doesn't). +6. **discovery embeds fine in Vite** with two caveats: it needs + `define: { global: 'globalThis' }` (throws `global is not defined` + otherwise — webpack shims this, Vite doesn't), and its CSS ships via + `?inline` import into the ViewModel's shadow root. Theming = + `host.colorScheme.set()` + `--discovery-*` custom props set on the host + element (`themeBridge` in `src/client/main.ts`). +7. **Render path**: `new ViewModel({ container, styles })` → + `host.view.render(host.dom.pageContent, { view: 'struct', expanded: 2 }, + data, {})`. No `setData` needed for per-query rendering. +8. **HAZARD (by design of live-query mode)**: a jora query can invoke any + function reachable as an own property of the live object + (`$f: danger.selfDestruct; $f()` mutated state in the spike — on the real + server that could be `close()`), and own getters fire during traversal. + jora also has no CPU/timeout limits. The real plugin must present this as + eval-grade access to the dev server (localhost-only posture, no third-party + exposure), and pin `jora >= 1.0.0-beta.16` (JS-injection fix landed in + beta.14). +9. **Versions pinned deliberately**: `jora@1.0.0-beta.16`, + `@discoveryjs/discovery@1.0.0-beta.99` — both beta with breaking renames + between betas; the real plugin should pin exact versions via a catalog. + +## Deviations from repo conventions (prototype-only) + +- `jora` + `@discoveryjs/discovery` pinned in the *default* catalog (pnpm + auto-promoted them); a real plugin would place them in a named catalog +- hand-rolled CSS chrome instead of UnoCSS + `@antfu/design` ports +- plain textarea editor; production should embed discovery's `QueryEditor` + (CodeMirror 5 + jora mode) with the async-suggestion re-trigger pattern + +## Run it + +```sh +pnpm --filter prototype-data-viewer spike # stage 1: node-only proof, prints 13 checks +pnpm --filter prototype-data-viewer dev # stage 2: http://localhost:5173/ +``` diff --git a/examples/prototype-data-viewer/index.html b/examples/prototype-data-viewer/index.html new file mode 100644 index 0000000..2b388f3 --- /dev/null +++ b/examples/prototype-data-viewer/index.html @@ -0,0 +1,142 @@ + + + + + + Data Viewer — PROTOTYPE + + + +
+
+ + Data Viewer + PROTOTYPE +
+ + +
+ connecting… + +
+
+
+ +
+
+
+
+
+
+ + + diff --git a/examples/prototype-data-viewer/package.json b/examples/prototype-data-viewer/package.json new file mode 100644 index 0000000..9c048e3 --- /dev/null +++ b/examples/prototype-data-viewer/package.json @@ -0,0 +1,22 @@ +{ + "name": "prototype-data-viewer", + "type": "module", + "version": "0.0.0", + "private": true, + "description": "PROTOTYPE (throwaway) — validates the live-query data-viewer architecture: server-side jora over live objects + RPC + themed discovery struct view. Not a real example; see README.", + "scripts": { + "dev": "vite --host", + "spike": "tsx src/spike.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@discoveryjs/discovery": "catalog:", + "devframe": "workspace:*", + "jora": "catalog:" + }, + "devDependencies": { + "get-port-please": "catalog:deps", + "tsx": "catalog:build", + "vite": "catalog:build" + } +} diff --git a/examples/prototype-data-viewer/src/client/main.ts b/examples/prototype-data-viewer/src/client/main.ts new file mode 100644 index 0000000..c1d563b --- /dev/null +++ b/examples/prototype-data-viewer/src/client/main.ts @@ -0,0 +1,224 @@ +/* eslint-disable antfu/no-top-level-await -- throwaway prototype; module-level bootstrap is the point */ +/** + * PROTOTYPE — throwaway code. STAGE 2 SPA. + * + * Validates the browser half of the live-query architecture: + * - connectDevframe() -> RPC to the side-car WS + * - a thin custom query editor (textarea) with REMOTE suggestions + * (jora stat-mode runs server-side; results arrive over RPC) + * - discovery's `struct` view as the result renderer, inside a ViewModel + * shadow root, themed via --discovery-* custom props to follow the + * chrome's light/dark scheme. + */ +import type { SuggestItem, SuggestOutcome } from '../rpc-contract' +import { ViewModel } from '@discoveryjs/discovery' +import discoveryCss from '@discoveryjs/discovery/dist/discovery.css?inline' +import { connectDevframe } from 'devframe/client' + +type QueryOutcome + = | { ok: true, result: unknown, stats: { queryMs: number, normalize: { ms: number, nodes: number, refs: number } } } + | { ok: false, error: { name: string, message: string } } + +interface SourceMeta { id: string, label: string, description?: string, examples?: string[] } + +const $ = (sel: string): T => document.querySelector(sel) as T +const sourceEl = $('#source') +const queryEl = $('#query') +const suggestEl = $('#suggest') +const examplesEl = $('#examples') +const errorEl = $('#error') +const statusEl = $('#status') +const resultEl = $('#result') + +// ── theme ────────────────────────────────────────────────────────────── +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)') +let dark = prefersDark.matches + +// ── discovery host (result renderer) ────────────────────────────────── +// Map discovery's custom props onto the prototype chrome palette so the +// panel follows the same light/dark scheme — the "themed discovery inside +// devframe chrome" decision under test. +const themeBridge = ` + :host { + --discovery-background-color: var(--proto-bg); + --discovery-color: var(--proto-color); + font-family: ui-monospace, 'DM Mono', monospace; + } +` +const host = new ViewModel({ + container: resultEl, + styles: [discoveryCss, themeBridge], + colorScheme: dark ? 'dark' : 'light', + colorSchemePersistent: false, +}) +await host.dom.ready + +function applyTheme(): void { + document.documentElement.classList.toggle('dark', dark) + document.documentElement.classList.toggle('light', !dark) + host.colorScheme.set(dark ? 'dark' : 'light') +} +$('#theme').addEventListener('click', () => { + dark = !dark + applyTheme() +}) +applyTheme() + +function syncPanelVars(): void { + // Custom props inherit through shadow boundaries — set them on the host el. + const styles = getComputedStyle(document.body) + resultEl.style.setProperty('--proto-bg', styles.getPropertyValue('background-color')) + resultEl.style.setProperty('--proto-color', styles.getPropertyValue('color')) +} +new MutationObserver(syncPanelVars).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) +syncPanelVars() + +async function renderResult(outcome: QueryOutcome, roundTripMs: number): Promise { + errorEl.style.display = 'none' + if (!outcome.ok) { + errorEl.textContent = `${outcome.error.name}: ${outcome.error.message}` + errorEl.style.display = 'block' + statusEl.textContent = 'query failed' + return + } + const { queryMs, normalize } = outcome.stats + statusEl.textContent + = `rpc ${roundTripMs.toFixed(0)}ms · jora ${queryMs}ms · normalize ${normalize.ms}ms · ${normalize.nodes} nodes${normalize.refs ? ` · ${normalize.refs} $refs` : ''}` + host.dom.pageContent.replaceChildren() + await host.view.render( + host.dom.pageContent, + { view: 'struct', expanded: 2 }, + outcome.result, + {}, + ) +} + +// ── rpc ──────────────────────────────────────────────────────────────── +const rpc = await connectDevframe({ baseURL: '/' }) +await rpc.ensureTrusted(10_000) +statusEl.textContent = 'connected' + +// Prototype functions are not module-augmented into DevframeRpcServerFunctions. +const call = rpc.call as unknown as (name: string, ...args: unknown[]) => Promise + +const sources = await call('data-viewer-proto:sources') as SourceMeta[] +for (const s of sources) { + const opt = document.createElement('option') + opt.value = s.id + opt.textContent = s.label + sourceEl.append(opt) +} + +function renderExamples(): void { + const source = sources.find(s => s.id === sourceEl.value) + examplesEl.replaceChildren() + for (const example of source?.examples ?? []) { + const chip = document.createElement('button') + chip.textContent = example + chip.addEventListener('click', () => { + queryEl.value = example + void runQuery() + }) + examplesEl.append(chip) + } +} +sourceEl.addEventListener('change', renderExamples) +renderExamples() + +async function runQuery(): Promise { + hideSuggest() + statusEl.textContent = 'running…' + const started = performance.now() + const outcome = await call('data-viewer-proto:query', sourceEl.value, queryEl.value) as QueryOutcome + await renderResult(outcome, performance.now() - started) +} +$('#run').addEventListener('click', () => void runQuery()) + +// ── remote suggestions ───────────────────────────────────────────────── +let items: SuggestItem[] = [] +let active = 0 +let suggestSeq = 0 + +function hideSuggest(): void { + suggestEl.style.display = 'none' + items = [] +} + +function acceptSuggestion(item: SuggestItem): void { + const text = queryEl.value + queryEl.value = text.slice(0, item.from) + item.value + text.slice(item.to) + const caret = item.from + item.value.length + queryEl.setSelectionRange(caret, caret) + queryEl.focus() + hideSuggest() +} + +function showSuggest(list: SuggestItem[]): void { + items = list + active = 0 + if (!list.length) { + hideSuggest() + return + } + suggestEl.replaceChildren() + list.forEach((item, i) => { + const row = document.createElement('div') + row.className = `item${i === active ? ' active' : ''}` + row.innerHTML = `` + ;(row.querySelector('.value') as HTMLElement).textContent = item.value + ;(row.querySelector('.type') as HTMLElement).textContent = item.type + row.addEventListener('mousedown', (e) => { + e.preventDefault() + acceptSuggestion(item) + }) + suggestEl.append(row) + }) + suggestEl.style.display = 'block' +} + +async function requestSuggestions(): Promise { + const seq = ++suggestSeq + const pos = queryEl.selectionStart ?? queryEl.value.length + const out = await call('data-viewer-proto:suggest', sourceEl.value, queryEl.value, pos) as SuggestOutcome + if (seq !== suggestSeq) + return // a newer request superseded this one + showSuggest(out.ok ? out.suggestions : []) +} + +let debounceTimer: ReturnType | undefined +queryEl.addEventListener('input', () => { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => void requestSuggestions(), 150) +}) + +queryEl.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault() + void runQuery() + return + } + if (e.ctrlKey && e.key === ' ') { + e.preventDefault() + void requestSuggestions() + return + } + if (items.length === 0) + return + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + active = (active + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length + suggestEl.querySelectorAll('.item').forEach((el, i) => el.classList.toggle('active', i === active)) + } + else if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault() + acceptSuggestion(items[active]) + } + else if (e.key === 'Escape') { + hideSuggest() + } +}) +queryEl.addEventListener('blur', () => setTimeout(hideSuggest, 150)) + +// Auto-run the first example so the page lands on a rendered result. +queryEl.value = 'config.plugins.name' +void runQuery() diff --git a/examples/prototype-data-viewer/src/demo-data.ts b/examples/prototype-data-viewer/src/demo-data.ts new file mode 100644 index 0000000..6b66067 --- /dev/null +++ b/examples/prototype-data-viewer/src/demo-data.ts @@ -0,0 +1,64 @@ +/** + * PROTOTYPE — throwaway code. + * + * Kitchen-sink demo graph: every shape the normalizer must survive. + */ + +class SessionStore { + name = 'sessions' + createdAt = new Date('2026-01-15T10:00:00Z') + private secret = 'own-private-field-visible' // own field (TS `private` is compile-time only) + entries = new Map([ + ['a1', { user: 'ada', hits: 3 }], + ['b2', { user: 'brendan', hits: 7 }], + ]) + + get upperName(): string { + return this.name.toUpperCase() // own-prototype getter: invisible to jora, fine + } + + touch(): string { + return `${this.secret}:touched` + } +} + +export interface DemoGraph { + [key: string]: unknown +} + +export function createDemoGraph(): DemoGraph { + const store = new SessionStore() + + const circularParent: Record = { name: 'parent' } + const circularChild: Record = { name: 'child', parent: circularParent } + circularParent.child = circularChild + + const graph: DemoGraph = { + title: 'kitchen sink', + counts: [1, 2, 3, 42], + bigArray: Array.from({ length: 500 }, (_, i) => ({ i, sq: i * i })), + store, + tags: new Set(['alpha', 'beta', 'gamma']), + lookup: new Map([ + ['string-key', 1], + [{ objKey: true }, 2], + ]), + circular: circularParent, + when: new Date('2026-07-16T00:00:00Z'), + pattern: /^devframe-.+$/i, + big: 12345678901234567890n, + sym: Symbol('demo'), + err: new TypeError('demo failure'), + pending: Promise.resolve('never seen'), + danger: { + // A function reachable in the graph — jora CAN invoke this. The spike + // demonstrates the hazard on this harmless canary instead of server.close. + selfDestruct: () => { + (graph as { destructed?: boolean }).destructed = true + return 'BOOM' + }, + }, + deep: { l1: { l2: { l3: { l4: { l5: { l6: { l7: { l8: { l9: { leaf: 'bottom' } } } } } } } } } }, + } + return graph +} diff --git a/examples/prototype-data-viewer/src/env.d.ts b/examples/prototype-data-viewer/src/env.d.ts new file mode 100644 index 0000000..a6cf0f0 --- /dev/null +++ b/examples/prototype-data-viewer/src/env.d.ts @@ -0,0 +1,21 @@ +/// + +/** PROTOTYPE — minimal jora typings (the package ships none). */ +declare module 'jora' { + export interface JoraQueryOptions { + tolerant?: boolean + stat?: boolean + } + export interface JoraSetupOptions { + methods?: Record unknown)> + assertions?: Record boolean)> + } + export type JoraQueryFn = (data: unknown, context?: unknown) => unknown + export interface Jora { + (query: string, options?: JoraQueryOptions): JoraQueryFn + setup: (options?: JoraSetupOptions) => (query: string, options?: JoraQueryOptions) => JoraQueryFn + version: string + } + const jora: Jora + export default jora +} diff --git a/examples/prototype-data-viewer/src/normalize.ts b/examples/prototype-data-viewer/src/normalize.ts new file mode 100644 index 0000000..62498bb --- /dev/null +++ b/examples/prototype-data-viewer/src/normalize.ts @@ -0,0 +1,216 @@ +/** + * PROTOTYPE — throwaway code. + * + * The result normalizer: walks an arbitrary live JS graph (jora query output) + * into a plain-JSON graph safe to send over RPC and feed to discovery's + * `struct` view. Handles what neither wire codec nor discovery can: + * + * - circular refs -> { $ref: '' } + * - Map -> { $type: 'Map', size, entries | value } + * - Set -> { $type: 'Set', size, values } + * - functions -> { $type: 'function', name } + * - class instances -> own enumerable props + $class tag + * - Date / RegExp / URL -> tagged string forms + * - BigInt / Symbol -> tagged string forms + * - Error -> { $type: 'Error', name, message } + * - Promise / WeakMap/.. -> opaque tags + * - depth / entry caps -> { $truncated: ... } markers + stats + */ + +export interface NormalizeOptions { + /** Max object/array nesting depth before truncation. */ + maxDepth?: number + /** Max array items / Map+Set entries emitted per collection. */ + maxEntries?: number + /** Max own properties emitted per object. */ + maxProps?: number + /** Max string length before truncation. */ + maxString?: number +} + +export interface NormalizeStats { + nodes: number + refs: number + truncatedDepth: number + truncatedEntries: number + truncatedProps: number + ms: number +} + +interface Walker { + seen: Map + stats: NormalizeStats + opts: Required +} + +const OPAQUE_TAGS: [abstract new (...args: never[]) => unknown, string][] = [] +// Guard: some of these globals may not exist in every runtime. +for (const name of ['WeakMap', 'WeakSet', 'WeakRef', 'ArrayBuffer', 'SharedArrayBuffer'] as const) { + const ctor = (globalThis as Record)[name] + if (typeof ctor === 'function') + OPAQUE_TAGS.push([ctor as never, name]) +} + +export function normalize(value: unknown, options: NormalizeOptions = {}): { data: unknown, stats: NormalizeStats } { + const start = performance.now() + const walker: Walker = { + seen: new Map(), + stats: { nodes: 0, refs: 0, truncatedDepth: 0, truncatedEntries: 0, truncatedProps: 0, ms: 0 }, + opts: { + maxDepth: options.maxDepth ?? 8, + maxEntries: options.maxEntries ?? 200, + maxProps: options.maxProps ?? 150, + maxString: options.maxString ?? 4000, + }, + } + const data = walk(value, walker, 0, '#') + walker.stats.ms = Math.round((performance.now() - start) * 100) / 100 + return { data, stats: walker.stats } +} + +function walk(value: unknown, w: Walker, depth: number, path: string): unknown { + w.stats.nodes++ + + // ── primitives ────────────────────────────────────────────────────── + if (value === null || value === undefined) + return value ?? null + const t = typeof value + if (t === 'string') { + const s = value as string + if (s.length > w.opts.maxString) + return `${s.slice(0, w.opts.maxString)}… [$truncated string, ${s.length} chars]` + return s + } + if (t === 'number') + return Number.isFinite(value as number) ? value : String(value) + if (t === 'boolean') + return value + if (t === 'bigint') + return { $type: 'bigint', value: String(value) } + if (t === 'symbol') + return { $type: 'symbol', value: String(value) } + if (t === 'function') { + const fn = value as { name?: string } + return { $type: 'function', name: fn.name || '(anonymous)' } + } + + // ── objects ───────────────────────────────────────────────────────── + const obj = value as object + + const seenPath = w.seen.get(obj) + if (seenPath !== undefined) { + w.stats.refs++ + return { $ref: seenPath } + } + + // Cheap non-recursive exotic types first. + if (obj instanceof Date) + return { $type: 'Date', value: Number.isNaN(obj.getTime()) ? 'Invalid Date' : obj.toISOString() } + if (obj instanceof RegExp) + return { $type: 'RegExp', value: String(obj) } + if (obj instanceof URL) + return { $type: 'URL', value: obj.href } + if (obj instanceof Error) { + return { $type: 'Error', name: obj.name, message: obj.message } + } + if (obj instanceof Promise) + return { $type: 'Promise' } + for (const [ctor, tag] of OPAQUE_TAGS) { + if (obj instanceof ctor) + return { $type: tag } + } + + if (depth >= w.opts.maxDepth) { + w.stats.truncatedDepth++ + return { $truncated: 'depth', $preview: preview(obj) } + } + + w.seen.set(obj, path) + + if (Array.isArray(obj)) { + const cap = Math.min(obj.length, w.opts.maxEntries) + const out: unknown[] = Array.from({ length: cap }) + for (let i = 0; i < cap; i++) + out[i] = walk(obj[i], w, depth + 1, `${path}[${i}]`) + if (obj.length > cap) { + w.stats.truncatedEntries++ + out.push({ $truncated: 'entries', $total: obj.length, $shown: cap }) + } + return out + } + + if (ArrayBuffer.isView(obj)) { + const view = obj as unknown as { length?: number, byteLength: number } + return { $type: obj.constructor?.name ?? 'TypedArray', length: view.length ?? view.byteLength } + } + + if (obj instanceof Map) { + const entries = [...obj.entries()].slice(0, w.opts.maxEntries) + if (obj.size > entries.length) + w.stats.truncatedEntries++ + const allStringKeys = entries.every(([k]) => typeof k === 'string') + if (allStringKeys) { + const value: Record = {} + for (const [k, v] of entries) + value[k as string] = walk(v, w, depth + 1, `${path}.${String(k)}`) + return { $type: 'Map', size: obj.size, value } + } + return { + $type: 'Map', + size: obj.size, + entries: entries.map(([k, v], i) => ({ + key: walk(k, w, depth + 1, `${path}~keys[${i}]`), + value: walk(v, w, depth + 1, `${path}~values[${i}]`), + })), + } + } + + if (obj instanceof Set) { + const values = [...obj].slice(0, w.opts.maxEntries) + if (obj.size > values.length) + w.stats.truncatedEntries++ + return { $type: 'Set', size: obj.size, values: values.map((v, i) => walk(v, w, depth + 1, `${path}~set[${i}]`)) } + } + + // Plain object or class instance: own enumerable string-keyed props. + const proto = Object.getPrototypeOf(obj) + const className = proto && proto !== Object.prototype && proto !== null + ? (proto.constructor?.name as string | undefined) + : undefined + + const out: Record = {} + if (className && className !== 'Object') + out.$class = className + + const keys = Object.keys(obj) + const cap = Math.min(keys.length, w.opts.maxProps) + for (let i = 0; i < cap; i++) { + const key = keys[i] + let v: unknown + try { + v = (obj as Record)[key] // own getters may fire or throw + } + catch (error) { + out[key] = { $type: 'getter-error', message: error instanceof Error ? error.message : String(error) } + continue + } + out[key] = walk(v, w, depth + 1, `${path}.${key}`) + } + if (keys.length > cap) { + w.stats.truncatedProps++ + out.$truncated = `props: showing ${cap} of ${keys.length}` + } + return out +} + +function preview(obj: object): string { + if (Array.isArray(obj)) + return `Array(${obj.length})` + if (obj instanceof Map) + return `Map(${obj.size})` + if (obj instanceof Set) + return `Set(${obj.size})` + const name = obj.constructor?.name ?? 'Object' + const keys = Object.keys(obj) + return `${name} { ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? ', …' : ''} }` +} diff --git a/examples/prototype-data-viewer/src/query-engine.ts b/examples/prototype-data-viewer/src/query-engine.ts new file mode 100644 index 0000000..656de38 --- /dev/null +++ b/examples/prototype-data-viewer/src/query-engine.ts @@ -0,0 +1,121 @@ +/** + * PROTOTYPE — throwaway code. + * + * Server-side jora execution against LIVE objects, plus stat-mode + * suggestions. Custom methods bridge jora's blind spots on live graphs + * (Map/Set are opaque to it; the prototype chain is not walked). + */ +import type { NormalizeOptions, NormalizeStats } from './normalize' +import type { SuggestItem, SuggestOutcome } from './rpc-contract' +import jora from 'jora' +import { normalize } from './normalize' + +export type { SuggestItem, SuggestOutcome } from './rpc-contract' + +/** + * Duck-typed Map detection: Vite 8's backward-compat `moduleGraph.idToModuleMap` + * is a Map-like facade (plain object with get/entries/size), not a real Map. + */ +function isMapLike(v: unknown): v is Map { + return !!v && typeof v === 'object' + && typeof (v as Map).entries === 'function' + && typeof (v as Map).get === 'function' +} + +function isSetLike(v: unknown): v is Set { + return !!v && typeof v === 'object' + && typeof (v as Set).has === 'function' + && typeof (v as Set)[Symbol.iterator] === 'function' + && typeof (v as Map).get !== 'function' +} + +const createQuery = jora.setup({ + methods: { + /** Map(-like) -> plain object (string-coerced keys). `idToModuleMap.fromMap()` */ + fromMap: v => isMapLike(v) ? Object.fromEntries(v.entries()) : v, + /** Map(-like) -> [{ key, value }] preserving key identity. */ + mapEntries: v => isMapLike(v) ? [...v.entries()].map(([key, value]) => ({ key, value })) : [], + /** Set(-like) -> array. */ + fromSet: v => isSetLike(v) ? [...v] : v, + /** Constructor name of any value. */ + typeOf: (v) => { + if (v === null) + return 'null' + if (typeof v !== 'object') + return typeof v + return (v as object).constructor?.name ?? 'Object' + }, + /** All own keys (incl. non-enumerable), as strings. */ + ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [], + }, +}) + +export interface QuerySuccess { + ok: true + result: unknown + stats: { queryMs: number, normalize: NormalizeStats } +} +export interface QueryFailure { + ok: false + error: { message: string, name: string } +} +export type QueryOutcome = QuerySuccess | QueryFailure + +export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome { + try { + const started = performance.now() + const raw = createQuery(query)(target) + const queryMs = Math.round((performance.now() - started) * 100) / 100 + const { data, stats } = normalize(raw, options) + return { ok: true, result: data, stats: { queryMs, normalize: stats } } + } + catch (error) { + const e = error instanceof Error ? error : new Error(String(error)) + return { ok: false, error: { message: e.message, name: e.name } } + } +} + +interface JoraStatEntry { + type: string + from: number + to: number + text: string + suggestions: unknown[] | null +} + +/** + * jora stat mode: evaluates the (tolerant) query against the live object and + * reports completions for the given cursor position. Each stat entry carries + * its candidates in a nested `suggestions` array — flattened here into plain, + * RPC-safe completion items. This is what makes remote autocomplete work. + */ +export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome { + try { + const started = performance.now() + const statApi = createQuery(query, { tolerant: true, stat: true })(target) as { + suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null + } + const raw = statApi.suggestion(pos, { limit }) ?? [] + const statMs = Math.round((performance.now() - started) * 100) / 100 + const suggestions: SuggestItem[] = [] + for (const entry of raw) { + for (const candidate of entry.suggestions ?? []) { + suggestions.push({ + type: String(entry.type), + from: entry.from, + to: entry.to, + current: String(entry.text ?? ''), + value: String(candidate).slice(0, 200), + }) + if (suggestions.length >= limit) + break + } + if (suggestions.length >= limit) + break + } + return { ok: true, suggestions, statMs } + } + catch (error) { + return { ok: false, suggestions: [], statMs: 0, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/examples/prototype-data-viewer/src/registry.ts b/examples/prototype-data-viewer/src/registry.ts new file mode 100644 index 0000000..49066e2 --- /dev/null +++ b/examples/prototype-data-viewer/src/registry.ts @@ -0,0 +1,46 @@ +/** + * PROTOTYPE — throwaway code. + * + * The data-source registry pattern under validation: a `WeakMap` keyed by the + * shared `DevframeNodeContext`, exactly like `plugins/git/src/rpc/context.ts` + * and core's `node/hub-internals/context.ts`. Because every plugin's `setup()` + * (and the host's `configureServer`) receive the SAME context object, anyone + * can register live objects here and the data-viewer can find them — with no + * dependency edge between contributor and viewer beyond this tiny module. + */ + +export interface DataSourceMeta { + id: string + label: string + description?: string + /** Example queries surfaced in the UI as one-click chips. */ + examples?: string[] +} + +export interface DataSourceEntry extends DataSourceMeta { + /** Lazy getter so registration can precede the object's creation. */ + getObject: () => unknown +} + +const registries = new WeakMap>() + +function sourcesFor(ctx: object): Map { + let map = registries.get(ctx) + if (!map) { + map = new Map() + registries.set(ctx, map) + } + return map +} + +export function registerDataSource(ctx: object, entry: DataSourceEntry): void { + sourcesFor(ctx).set(entry.id, entry) +} + +export function listDataSources(ctx: object): DataSourceMeta[] { + return Array.from(sourcesFor(ctx).values()).map(({ getObject: _, ...meta }) => meta) +} + +export function getDataSource(ctx: object, id: string): DataSourceEntry | undefined { + return sourcesFor(ctx).get(id) +} diff --git a/examples/prototype-data-viewer/src/rpc-contract.ts b/examples/prototype-data-viewer/src/rpc-contract.ts new file mode 100644 index 0000000..c5ea5b2 --- /dev/null +++ b/examples/prototype-data-viewer/src/rpc-contract.ts @@ -0,0 +1,24 @@ +/** + * PROTOTYPE — throwaway code. + * + * Wire types shared by the server RPC functions and the SPA. Types only — + * safe to import from browser code without dragging jora into the bundle. + */ + +/** One completion candidate: replace [from, to) with `value`. */ +export interface SuggestItem { + type: string + from: number + to: number + /** The fragment currently typed in that range. */ + current: string + /** The completion to insert. */ + value: string +} + +export interface SuggestOutcome { + ok: boolean + suggestions: SuggestItem[] + statMs: number + error?: string +} diff --git a/examples/prototype-data-viewer/src/rpc-functions.ts b/examples/prototype-data-viewer/src/rpc-functions.ts new file mode 100644 index 0000000..588b61b --- /dev/null +++ b/examples/prototype-data-viewer/src/rpc-functions.ts @@ -0,0 +1,55 @@ +/** + * PROTOTYPE — throwaway code. + * + * The three RPC functions a real data-viewer plugin would ship, modeled on + * `plugins/inspect`'s `invoke` (error envelope, structured result). All + * results are pre-normalized to plain JSON, so `jsonSerializable: true` + * keeps the wire strict and proves wire-safety on every call. + */ +import type { DevframeNodeContext } from 'devframe/types' +import { createDefineWrapperWithContext } from 'devframe/rpc' +import { runQuery, suggest } from './query-engine' +import { getDataSource, listDataSources } from './registry' + +const defineRpc = createDefineWrapperWithContext() + +const NS = 'data-viewer-proto' + +export const sourcesFn = defineRpc({ + name: `${NS}:sources`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async () => listDataSources(ctx), + }), +}) + +export const queryFn = defineRpc({ + name: `${NS}:query`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async (sourceId: string, joraQuery: string, options?: { maxDepth?: number, maxEntries?: number }) => { + const source = getDataSource(ctx, sourceId) + if (!source) + return { ok: false as const, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } } + return runQuery(source.getObject(), joraQuery, options) + }, + }), +}) + +export const suggestFn = defineRpc({ + name: `${NS}:suggest`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async (sourceId: string, joraQuery: string, pos: number) => { + const source = getDataSource(ctx, sourceId) + if (!source) + return { ok: false, suggestions: [], statMs: 0, error: `No data source "${sourceId}"` } + return suggest(source.getObject(), joraQuery, pos) + }, + }), +}) + +export const allRpcFunctions = [sourcesFn, queryFn, suggestFn] diff --git a/examples/prototype-data-viewer/src/spike.ts b/examples/prototype-data-viewer/src/spike.ts new file mode 100644 index 0000000..05157eb --- /dev/null +++ b/examples/prototype-data-viewer/src/spike.ts @@ -0,0 +1,224 @@ +/* eslint-disable no-console, antfu/no-top-level-await -- throwaway spike script; console output IS the deliverable */ +/** + * PROTOTYPE — throwaway code. STAGE 1 spike (node-only). + * + * Question: can server-side jora usefully query a LIVE ViteDevServer, with + * custom methods bridging Map/Set, and can the normalizer make the results + * wire-safe? Run: `pnpm --filter prototype-data-viewer spike` + */ +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import { createDemoGraph } from './demo-data' +import { normalize } from './normalize' +import { runQuery, suggest } from './query-engine' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +interface Check { + name: string + pass: boolean + detail: string +} +const checks: Check[] = [] + +function check(name: string, pass: boolean, detail: string): void { + checks.push({ name, pass, detail }) + console.log(`${pass ? 'PASS' : 'FAIL'} ${name}\n ${detail}\n`) +} + +function short(v: unknown, n = 220): string { + const s = JSON.stringify(v) + return s && s.length > n ? `${s.slice(0, n)}…` : String(s) +} + +// ─── live Vite server ──────────────────────────────────────────────────── +console.log('creating a real ViteDevServer (middlewareMode)…\n') +const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true }, + optimizeDeps: { noDiscovery: true }, +}) +// Populate the module graph with a couple of real transforms. +await server.transformRequest('/src/demo-data.ts') +await server.transformRequest('/src/normalize.ts') + +// 1. Top-level shape of the live server +{ + const out = runQuery(server, 'ownKeys()') + check( + 'jora runs on live ViteDevServer (ownKeys)', + out.ok && Array.isArray(out.result) && (out.result as unknown[]).includes('config'), + out.ok ? `keys: ${short(out.result, 160)} in ${out.stats.queryMs}ms` : out.error.message, + ) +} + +// 2. Plugin names via implicit array mapping +{ + const out = runQuery(server, 'config.plugins.name') + check( + 'config.plugins.name (implicit map over array)', + out.ok && Array.isArray(out.result) && (out.result as unknown[]).length > 0, + out.ok ? `${(out.result as unknown[]).length} plugins in ${out.stats.queryMs}ms: ${short(out.result, 140)}` : out.error.message, + ) +} + +// 3. Module graph Map via custom method (vite 8 mixed graph) +{ + const out = runQuery(server, 'moduleGraph.idToModuleMap.mapEntries().key') + const ids = out.ok ? (out.result as unknown[]) : [] + check( + 'moduleGraph Map via mapEntries() custom method', + out.ok && ids.length >= 2, + out.ok ? `${ids.length} modules in ${out.stats.queryMs}ms: ${short(ids, 160)}` : (out as { error: { message: string } }).error.message, + ) +} + +// 4. Environments API (vite 8) — nested live class instances +{ + const out = runQuery(server, 'environments.keys()') + check( + 'vite 8 environments reachable', + out.ok && Array.isArray(out.result) && (out.result as unknown[]).includes('client'), + out.ok ? short(out.result, 160) : out.error.message, + ) +} + +// 5. A query returning live ModuleNode class instances -> normalized +{ + const out = runQuery(server, 'moduleGraph.idToModuleMap.mapEntries().value[0:2]', { maxDepth: 4 }) + const wireable = out.ok ? isJsonSafe(out.result) : false + check( + 'ModuleNode instances normalize to wire-safe JSON', + out.ok && wireable, + out.ok + ? `query ${out.stats.queryMs}ms, normalize ${out.stats.normalize.ms}ms (${out.stats.normalize.nodes} nodes, ${out.stats.normalize.refs} $refs): ${short(out.result, 200)}` + : out.error.message, + ) +} + +// 6. Whole resolved config normalized (the stress test: plugins = functions everywhere) +{ + const out = runQuery(server, 'config', { maxDepth: 6 }) + const wireable = out.ok ? isJsonSafe(out.result) : false + check( + 'entire ResolvedConfig normalizes + JSON-serializes', + out.ok && wireable, + out.ok + ? `query ${out.stats.queryMs}ms, normalize ${out.stats.normalize.ms}ms, ${out.stats.normalize.nodes} nodes, JSON ${byteLen(out.result)} bytes` + : out.error.message, + ) +} + +// 7. Suggestions (stat mode) against the live server +{ + const q = 'config.' + const out = suggest(server, q, q.length) + const values = out.suggestions.map(s => s.value) + check( + 'stat-mode suggestions on live server (autocomplete over RPC)', + out.ok && values.includes('plugins'), + out.ok ? `${out.suggestions.length} completions in ${out.statMs}ms: ${short(values.slice(0, 8), 200)}` : String(out.error), + ) +} + +// 8. Suggestion timing on a heavier position (mid-graph, env API) +{ + // NOTE: the mixed-graph compat `ModuleNode`s are getter-facades (own keys: + // _moduleGraph/_clientModule/_ssrModule only) — barely queryable. The + // environment module graph holds the real nodes with own fields. + const q = 'environments.client.moduleGraph.idToModuleMap.mapEntries().value.' + const out = suggest(server, q, q.length) + const values = out.suggestions.map(s => s.value) + check( + 'suggestions mid-query over live env module graph', + out.ok && values.includes('url'), + out.ok ? `${out.suggestions.length} completions in ${out.statMs}ms: ${short(values.slice(0, 8), 160)}` : String(out.error), + ) +} + +// ─── demo graph: normalizer battery + hazard demo ─────────────────────── +const demo = createDemoGraph() + +// 9. Kitchen sink normalizes and survives JSON round-trip +{ + const { data, stats } = normalize(demo, { maxDepth: 8, maxEntries: 50 }) + check( + 'kitchen-sink graph (Map/Set/circular/BigInt/Error/class) normalizes', + isJsonSafe(data), + `${stats.nodes} nodes, ${stats.refs} circular $refs, ${stats.truncatedEntries} entry-caps, ${stats.ms}ms`, + ) +} + +// 10. Circular ref becomes $ref +{ + const out = runQuery(demo, 'circular') + const s = JSON.stringify(out.ok ? out.result : null) + check('circular refs -> { $ref } markers', out.ok && s.includes('$ref'), short(out.ok ? out.result : out, 200)) +} + +// 11. Class instance: own fields visible (incl. TS-private), proto getter invisible +{ + const out = runQuery(demo, 'store.ownKeys()') + const keys = out.ok ? out.result as string[] : [] + check( + 'class instance: own fields queryable, proto getters invisible', + out.ok && keys.includes('secret') && !keys.includes('upperName'), + `ownKeys: ${short(keys, 160)}`, + ) +} + +// 12. THE HAZARD: a query can invoke functions reachable in the graph +{ + const before = (demo as { destructed?: boolean }).destructed + const out = runQuery(demo, '$f: danger.selfDestruct; $f()') + const after = (demo as { destructed?: boolean }).destructed + check( + 'HAZARD confirmed: query invoked a live function and mutated state', + out.ok && before === undefined && after === true, + `result=${short(out.ok ? out.result : out.error.message)} — on a real server this could be close(). Live-query mode must ship documented as eval-grade access.`, + ) +} + +// 13. Query error surfaces cleanly (no crash) +{ + const out = runQuery(demo, 'store.entries.size()') // Map opaque to raw jora + const outBad = runQuery(demo, 'nonexistent.method()') + check( + 'bad queries fail soft (error envelope, process alive)', + out.ok && !outBad.ok, + `Map w/o helper -> ${short(out.ok ? out.result : out.error.message)}; undefined method -> ${outBad.ok ? short(outBad.result) : outBad.error.message}`, + ) +} + +await server.close() + +// ─── verdict ───────────────────────────────────────────────────────────── +const failed = checks.filter(c => !c.pass) +console.log('─'.repeat(72)) +console.log(`SPIKE VERDICT: ${checks.length - failed.length}/${checks.length} checks passed`) +if (failed.length) { + for (const f of failed) console.log(` FAILED: ${f.name}`) + process.exitCode = 1 +} + +function isJsonSafe(v: unknown): boolean { + try { + const s = JSON.stringify(v) + return s !== undefined && (JSON.parse(s), true) + } + catch { + return false + } +} + +function byteLen(v: unknown): number { + try { + return new TextEncoder().encode(JSON.stringify(v)).length + } + catch { + return -1 + } +} diff --git a/examples/prototype-data-viewer/tsconfig.json b/examples/prototype-data-viewer/tsconfig.json new file mode 100644 index 0000000..490c315 --- /dev/null +++ b/examples/prototype-data-viewer/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "esModuleInterop": true, + "isolatedDeclarations": false + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/prototype-data-viewer/vite.config.ts b/examples/prototype-data-viewer/vite.config.ts new file mode 100644 index 0000000..c2acec7 --- /dev/null +++ b/examples/prototype-data-viewer/vite.config.ts @@ -0,0 +1,107 @@ +/** + * PROTOTYPE — throwaway code. STAGE 2 host. + * + * A minimal Vite host wiring the data-viewer prototype end to end: + * devframe context + WS side-car (like examples/minimal-vite-devframe-hub), + * the live ViteDevServer registered as a queryable data source — the exact + * "inspect the Vite server instance" scenario the plugin idea came from. + */ +import type { DevframeHost } from 'devframe/types' +import type { Plugin, ViteDevServer } from 'vite' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { createHostContext, startHttpAndWs } from 'devframe/node' +import { getPort } from 'get-port-please' +import { defineConfig } from 'vite' +import { createDemoGraph } from './src/demo-data' +import { registerDataSource } from './src/registry' +import { allRpcFunctions } from './src/rpc-functions' + +function dataViewerProtoHost(): Plugin { + let started: { close: () => Promise } | undefined + + return { + name: 'prototype-data-viewer-host', + apply: 'serve', + async configureServer(server: ViteDevServer) { + await started?.close().catch(() => {}) + started = undefined + + const cwd = server.config.root + const port = await getPort({ port: 9878, portRange: [9878, 9978] }) + + const host: DevframeHost = { + mountStatic() {}, + mountConnectionMeta() {}, + resolveOrigin() { + const resolved = server.resolvedUrls?.local?.[0] + return resolved ? new URL(resolved).origin : 'http://localhost:5173' + }, + getStorageDir(scope) { + return scope === 'workspace' + ? join(cwd, 'node_modules/.prototype-data-viewer') + : join(homedir(), '.prototype-data-viewer') + }, + } + + const context = await createHostContext({ cwd, mode: 'dev', host }) + + // The whole point: the live ViteDevServer, registered as a data source. + registerDataSource(context, { + id: 'vite:server', + label: 'Vite Dev Server (live)', + description: 'The ViteDevServer instance serving this very page.', + examples: [ + 'ownKeys()', + 'config.plugins.name', + 'moduleGraph.idToModuleMap.mapEntries().key', + 'environments.keys()', + 'config.resolve', + ], + getObject: () => server, + }) + + const demo = createDemoGraph() + registerDataSource(context, { + id: 'demo:kitchen-sink', + label: 'Kitchen-sink demo object', + description: 'Maps, Sets, circular refs, class instances, BigInt, functions.', + examples: [ + 'ownKeys()', + 'store.entries.mapEntries()', + 'tags.fromSet()', + 'circular', + 'bigArray[0:5]', + ], + getObject: () => demo, + }) + + for (const fn of allRpcFunctions) + context.rpc.register(fn) + + started = await startHttpAndWs({ context, port, auth: false }) + + // Let the SPA discover the WS endpoint via ./__connection.json + server.middlewares.use(`/${DEVFRAME_CONNECTION_META_FILENAME}`, (_req, res) => { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + }) + + server.httpServer?.once('close', () => { + void started?.close().catch(() => {}) + }) + }, + async closeBundle() { + await started?.close().catch(() => {}) + started = undefined + }, + } +} + +export default defineConfig({ + // discovery (or a dep) references the Node-style `global`; webpack shims + // it by default, Vite needs the classic define. FINDING for the real plugin. + define: { global: 'globalThis' }, + plugins: [dataViewerProtoHost()], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b91d9e9..7e23055 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,13 @@ catalogs: vite-plugin-solid: specifier: ^2.11.12 version: 2.11.12 + default: + '@discoveryjs/discovery': + specifier: 1.0.0-beta.99 + version: 1.0.0-beta.99 + jora: + specifier: 1.0.0-beta.16 + version: 1.0.0-beta.16 deps: '@modelcontextprotocol/sdk': specifier: ^1.29.0 @@ -597,6 +604,28 @@ importers: specifier: catalog:deps version: 8.21.1 + examples/prototype-data-viewer: + dependencies: + '@discoveryjs/discovery': + specifier: 'catalog:' + version: 1.0.0-beta.99 + devframe: + specifier: workspace:* + version: link:../../packages/devframe + jora: + specifier: 'catalog:' + version: 1.0.0-beta.16 + devDependencies: + get-port-please: + specifier: catalog:deps + version: 3.2.0 + tsx: + specifier: catalog:build + version: 4.23.1 + vite: + specifier: catalog:build + version: 8.1.4(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + examples/storybook-hub: dependencies: '@antfu/design': @@ -1575,6 +1604,18 @@ packages: '@colordx/core@5.4.3': resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==} + '@discoveryjs/discovery@1.0.0-beta.99': + resolution: {integrity: sha512-Dy374TnEbEJpgNDogwHHeY9bMFwnNOvs5V8qGZmMyfn0irGsoKldb+wi6d5xY+9DZ9G3D6WJ9M/jKaB7bjvJqg==} + engines: {node: '>=8.0.0'} + + '@discoveryjs/json-ext@0.6.3': + resolution: {integrity: sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==} + engines: {node: '>=14.17.0'} + + '@discoveryjs/natural-compare@1.1.0': + resolution: {integrity: sha512-yuctPJs5lRXoI8LkpVZGAV6n+DKOuEsfpfcIDQ8ZjWHwazqk1QjBc4jMlof0UlZHyUqv4dwsOTooMiAmtzvwXA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + '@docsearch/css@4.6.3': resolution: {integrity: sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==} @@ -5214,6 +5255,10 @@ packages: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -5548,10 +5593,19 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + codemirror@5.65.21: + resolution: {integrity: sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -6677,6 +6731,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hitext@1.0.0-beta.1: + resolution: {integrity: sha512-H7ph5MF3lrc8BE47hhyHi+ZgoRiGi5kqxnA5Ou6rg9dVHtPnmUU8/Man19X/H3Cj1C/DF+jsN/ch9eV7nfpzMQ==} + engines: {node: '>=8.0.0'} + hono@4.12.18: resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} engines: {node: '>=16.9.0'} @@ -6899,6 +6957,14 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jora@1.0.0-beta.15: + resolution: {integrity: sha512-P3Ztojc+qvHbnqdS7vrIbt5TUoiYBD+o/ICvw9Paf+ozr2+dCnCBetmteojv6ISPBrBnE/5ZlCjBMe96YqYnoA==} + engines: {node: ^10.12.0 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + jora@1.0.0-beta.16: + resolution: {integrity: sha512-gkR5tmjfhAC7GwuAbXSDOG691Ei3VLTvkKc5hGG5NoPWPwqSTCy0Q3I39dH1Aae0ExC3OXHfHjZSZtFDwqfRwg==} + engines: {node: '>=18.0.0'} + jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} @@ -7166,6 +7232,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.1.4: + resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -9846,6 +9917,20 @@ snapshots: '@colordx/core@5.4.3': {} + '@discoveryjs/discovery@1.0.0-beta.99': + dependencies: + '@discoveryjs/json-ext': 0.6.3 + codemirror: 5.65.21 + diff: 8.0.4 + github-slugger: 2.0.0 + hitext: 1.0.0-beta.1 + jora: 1.0.0-beta.15 + marked: 14.1.4 + + '@discoveryjs/json-ext@0.6.3': {} + + '@discoveryjs/natural-compare@1.1.0': {} + '@docsearch/css@4.6.3': {} '@docsearch/js@4.6.3': {} @@ -11311,7 +11396,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -13151,6 +13236,10 @@ snapshots: ansi-regex@6.2.2: {} + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 @@ -13481,10 +13570,18 @@ snapshots: cluster-key-slot@1.1.2: {} + codemirror@5.65.21: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + color-convert@2.0.1: dependencies: color-name: 1.1.4 + color-name@1.1.3: {} + color-name@1.1.4: {} colorette@2.0.20: {} @@ -14700,6 +14797,10 @@ snapshots: he@1.2.0: {} + hitext@1.0.0-beta.1: + dependencies: + ansi-styles: 3.2.1 + hono@4.12.18: {} hookable@5.5.3: {} @@ -14884,6 +14985,14 @@ snapshots: jiti@2.7.0: {} + jora@1.0.0-beta.15: + dependencies: + '@discoveryjs/natural-compare': 1.1.0 + + jora@1.0.0-beta.16: + dependencies: + '@discoveryjs/natural-compare': 1.1.0 + jose@6.2.3: {} js-stringify@1.0.2: {} @@ -15119,6 +15228,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.1.4: {} + marked@16.4.2: {} math-intrinsics@1.1.0: {} @@ -16759,7 +16870,7 @@ snapshots: rollup-plugin-visualizer@7.0.1(rolldown@1.1.4)(rollup@4.60.3): dependencies: open: 11.0.0 - picomatch: 4.0.4 + picomatch: 4.0.5 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: @@ -16769,7 +16880,7 @@ snapshots: rollup-plugin-visualizer@7.0.1(rolldown@1.1.5)(rollup@4.60.3): dependencies: open: 11.0.0 - picomatch: 4.0.4 + picomatch: 4.0.5 source-map: 0.7.6 yargs: 18.0.0 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cbfa4f2..c835af0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,6 +31,9 @@ overrides: crossws: ^0.4.10 semver: ^7.8.5 shell-quote: ^1.10.0 +catalog: + '@discoveryjs/discovery': 1.0.0-beta.99 + jora: 1.0.0-beta.16 catalogs: build: '@antfu/ni': ^30.2.0 From 7f4e6656d76486bbe638585f97148e8513db03a5 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 16 Jul 2026 05:05:16 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(prototype):=20data-inspector=20rewor?= =?UTF-8?q?k=20=E2=80=94=20Vue/@antfu/design=20workbench,=20source=20contr?= =?UTF-8?q?act,=20saved=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../.devframe/data-inspector/queries.json | 12 + examples/prototype-data-inspector/README.md | 98 ++++++++ examples/prototype-data-inspector/index.html | 12 + .../prototype-data-inspector/package.json | 30 +++ .../src/demo-data.ts | 0 .../src/env.d.ts | 7 + .../src/normalize.ts | 0 .../src/query-engine.ts | 19 +- .../prototype-data-inspector/src/registry.ts | 90 +++++++ .../src/rpc-contract.ts | 76 ++++++ .../src/rpc-functions.ts | 84 +++++++ .../src/saved-queries.ts | 100 ++++++++ .../prototype-data-inspector/src/spa/App.vue | 118 +++++++++ .../src/spa/components/QueryEditor.vue | 115 +++++++++ .../src/spa/components/ResultViewer.vue | 62 +++++ .../src/spa/components/SavedQueriesPanel.vue | 111 +++++++++ .../src/spa/composables/discovery.ts | 65 +++++ .../src/spa/composables/rpc.ts | 45 ++++ .../src/spa/composables/saved.ts | 27 +++ .../src/spa/composables/scheme.ts | 17 ++ .../src/spa/composables/workbench.ts | 175 ++++++++++++++ .../prototype-data-inspector/src/spa/main.ts | 11 + .../src/spa/shims.d.ts | 13 + .../src/spa/style.css | 20 ++ .../src/spike.ts | 0 .../tsconfig.json | 0 .../prototype-data-inspector/uno.config.ts | 41 ++++ .../vite.config.ts | 67 +++--- examples/prototype-data-viewer/README.md | 91 ------- examples/prototype-data-viewer/index.html | 142 ----------- examples/prototype-data-viewer/package.json | 22 -- .../prototype-data-viewer/src/client/main.ts | 224 ------------------ .../prototype-data-viewer/src/registry.ts | 46 ---- .../prototype-data-viewer/src/rpc-contract.ts | 24 -- .../src/rpc-functions.ts | 55 ----- pnpm-lock.yaml | 94 +++++--- pnpm-workspace.yaml | 1 + 37 files changed, 1442 insertions(+), 672 deletions(-) create mode 100644 examples/prototype-data-inspector/.devframe/data-inspector/queries.json create mode 100644 examples/prototype-data-inspector/README.md create mode 100644 examples/prototype-data-inspector/index.html create mode 100644 examples/prototype-data-inspector/package.json rename examples/{prototype-data-viewer => prototype-data-inspector}/src/demo-data.ts (100%) rename examples/{prototype-data-viewer => prototype-data-inspector}/src/env.d.ts (74%) rename examples/{prototype-data-viewer => prototype-data-inspector}/src/normalize.ts (100%) rename examples/{prototype-data-viewer => prototype-data-inspector}/src/query-engine.ts (90%) create mode 100644 examples/prototype-data-inspector/src/registry.ts create mode 100644 examples/prototype-data-inspector/src/rpc-contract.ts create mode 100644 examples/prototype-data-inspector/src/rpc-functions.ts create mode 100644 examples/prototype-data-inspector/src/saved-queries.ts create mode 100644 examples/prototype-data-inspector/src/spa/App.vue create mode 100644 examples/prototype-data-inspector/src/spa/components/QueryEditor.vue create mode 100644 examples/prototype-data-inspector/src/spa/components/ResultViewer.vue create mode 100644 examples/prototype-data-inspector/src/spa/components/SavedQueriesPanel.vue create mode 100644 examples/prototype-data-inspector/src/spa/composables/discovery.ts create mode 100644 examples/prototype-data-inspector/src/spa/composables/rpc.ts create mode 100644 examples/prototype-data-inspector/src/spa/composables/saved.ts create mode 100644 examples/prototype-data-inspector/src/spa/composables/scheme.ts create mode 100644 examples/prototype-data-inspector/src/spa/composables/workbench.ts create mode 100644 examples/prototype-data-inspector/src/spa/main.ts create mode 100644 examples/prototype-data-inspector/src/spa/shims.d.ts create mode 100644 examples/prototype-data-inspector/src/spa/style.css rename examples/{prototype-data-viewer => prototype-data-inspector}/src/spike.ts (100%) rename examples/{prototype-data-viewer => prototype-data-inspector}/tsconfig.json (100%) create mode 100644 examples/prototype-data-inspector/uno.config.ts rename examples/{prototype-data-viewer => prototype-data-inspector}/vite.config.ts (62%) delete mode 100644 examples/prototype-data-viewer/README.md delete mode 100644 examples/prototype-data-viewer/index.html delete mode 100644 examples/prototype-data-viewer/package.json delete mode 100644 examples/prototype-data-viewer/src/client/main.ts delete mode 100644 examples/prototype-data-viewer/src/registry.ts delete mode 100644 examples/prototype-data-viewer/src/rpc-contract.ts delete mode 100644 examples/prototype-data-viewer/src/rpc-functions.ts diff --git a/examples/prototype-data-inspector/.devframe/data-inspector/queries.json b/examples/prototype-data-inspector/.devframe/data-inspector/queries.json new file mode 100644 index 0000000..06fbbcd --- /dev/null +++ b/examples/prototype-data-inspector/.devframe/data-inspector/queries.json @@ -0,0 +1,12 @@ +{ + "queries": { + "plugin-names": { + "id": "plugin-names", + "title": "Plugin names", + "description": "All Vite plugin names in order", + "query": "config.plugins.name", + "sourceId": "vite:server", + "updatedAt": 1784177708176 + } + } +} diff --git a/examples/prototype-data-inspector/README.md b/examples/prototype-data-inspector/README.md new file mode 100644 index 0000000..1af5e6a --- /dev/null +++ b/examples/prototype-data-inspector/README.md @@ -0,0 +1,98 @@ +# prototype-data-inspector + +> **PROTOTYPE — throwaway code.** This is not an example and not a plugin. +> It exists to answer one question and is kept on its spike branch as a +> primary source. Do not merge, extend, or depend on it. + +## The question + +Can a **data-inspector plugin** work on devframe? Architecture under test: +other plugins/hosts register live server objects into a registry; the UI +composes jora queries that execute **server-side against the live object**; +normalized results return over devframe RPC and render in a **themed +discovery `struct` view** inside a **Vue + `@antfu/design`** workbench, with +saved queries persisted per user or per project. + +## Verdict: YES — validated end to end + +Stage 1 (`pnpm --filter prototype-data-inspector spike`): **13/13 checks** +against a real programmatic `ViteDevServer` (jora over live objects, Map/Set +bridge methods, normalizer, stat-mode suggestions, function-invocation hazard). + +Stage 2 (`pnpm --filter prototype-data-inspector dev`): the full workbench +against the Vite server serving the page itself — left editor / right viewer +split panes, debounced auto-run, client-side syntax gate, non-destructive +errors, per-query stats (jora / normalize / rpc timings, payload size, node +count), remote autocomplete, saved queries in both scopes, synced dark mode. + +## The pieces (what the real plugin reuses) + +- **`src/registry.ts`** — data sources as + `{ id, title, description?, getData(): any, static?: boolean }`, registered + dynamically against the shared `DevframeNodeContext` + (`WeakMap` idiom from `plugins/git`). `static: true` memoizes `getData()`; + `registerDataSource` returns an unregister fn and fires change listeners. +- **`src/normalize.ts`** — the core asset: live graph -> strict JSON + (circular -> `$ref`, Map/Set/class/function/BigInt/Error tagging, + depth/entry/prop/string caps) with stats. +- **`src/query-engine.ts`** — jora with duck-typed `fromMap()` / + `mapEntries()` / `fromSet()` / `ownKeys()` / `typeOf()` bridges, plus + flattened stat-mode suggestions. +- **`src/saved-queries.ts`** — id-keyed saved queries via devframe + `createStorage` (debounced atomic JSON): + `user` -> `node_modules/.data-inspector/queries.json` (per checkout), + `project` -> `.devframe/data-inspector/queries.json` (committable, shared). + Saving an id into one scope removes its twin from the other. +- **`src/rpc-functions.ts`** — `data-inspector:{sources,query,suggest}` + + `data-inspector:saved:{list,save,delete}`, all `jsonSerializable`. +- **`src/spa/`** — Vue 3 + `@antfu/design` workbench: canonical uno preset + block (mirrors `plugins/inspect`), design components used directly + (`LayoutToolbar`, `LayoutSplitPane`, `FormSelect`, `FormTextInput`, + `ActionButton`/`ActionIconButton`/`ActionDarkToggle`, + `DisplayBadge`/`DisplayBytes`/`DisplayDuration`), discovery `ViewModel` + bound to a container ref with the default page redefined as a struct view. + +## Findings (things the real plugin must know) + +1. **Vite 8 compat surfaces**: `server.moduleGraph.idToModuleMap` is + Map-*shaped* but not a `Map` (duck-type it), and compat `ModuleNode`s are + getter facades whose own keys are `_moduleGraph`/`_clientModule`/`_ssrModule` + only — query `environments..moduleGraph` for real own fields. +2. **discovery under Vite** needs `define: { global: 'globalThis' }` and its + CSS injected into the shadow root via `?inline` import. Render results + through the model (`page.define('default', ...)` + `setData`) — rendering + into `dom.pageContent` manually races discovery's own page cycle. +3. **Theming**: `host.colorScheme.set()` + `--discovery-*` custom props + (bridged to design tokens through CSS vars that flip with `.dark`). +4. **jora syntax check runs client-side** (`jora.syntax.parse`, ~20 KB gzip): + parse errors carry `details.loc.range`, so end-of-input errors show as a + soft "keep typing" state and malformed queries never hit the wire. +5. **Suggestions** arrive nested per stat entry (flatten server-side); + prefix-filtering is the client's job. +6. **`DisplayBadge` with an explicit CSS color** sets fg and bg to the same + value — pass a hue number (theme-aware tint) or use `variant="solid"`. +7. **`LayoutSplitPane`** needs `splitpanes` (+ `Pane`) as a direct dep — it's + an `@antfu/design` peer, not a bundled dep. +8. **HAZARD (by design of live-query mode)**: a jora query can invoke any + function reachable as an own property of the live object and fires own + getters; no CPU/timeout limits. Present it as eval-grade access to the dev + server (localhost-only), and pin `jora >= 1.0.0-beta.16`. +9. **Versions pinned deliberately**: `jora@1.0.0-beta.16`, + `@discoveryjs/discovery@1.0.0-beta.99` — both beta with breaking renames + between betas. + +## Deviations from repo conventions (prototype-only) + +- `jora` + `@discoveryjs/discovery` pinned in the *default* catalog (pnpm + auto-promoted them); a real plugin would place them in a named catalog +- plain textarea editor; production should embed discovery's `QueryEditor` + (CodeMirror 5 + jora mode) with the async-suggestion re-trigger pattern +- `.vue` internals aren't typechecked (`tsc --noEmit` + shims, same as the + existing plugins) + +## Run it + +```sh +pnpm --filter prototype-data-inspector spike # stage 1: node-only proof, prints 13 checks +pnpm --filter prototype-data-inspector dev # stage 2: http://localhost:5173/ +``` diff --git a/examples/prototype-data-inspector/index.html b/examples/prototype-data-inspector/index.html new file mode 100644 index 0000000..6fbcdbd --- /dev/null +++ b/examples/prototype-data-inspector/index.html @@ -0,0 +1,12 @@ + + + + + + Data Inspector — PROTOTYPE + + +
+ + + diff --git a/examples/prototype-data-inspector/package.json b/examples/prototype-data-inspector/package.json new file mode 100644 index 0000000..7276e60 --- /dev/null +++ b/examples/prototype-data-inspector/package.json @@ -0,0 +1,30 @@ +{ + "name": "prototype-data-inspector", + "type": "module", + "version": "0.0.0", + "private": true, + "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.", + "scripts": { + "dev": "vite --host", + "spike": "tsx src/spike.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@antfu/design": "catalog:frontend", + "@discoveryjs/discovery": "catalog:", + "devframe": "workspace:*", + "floating-vue": "catalog:frontend", + "jora": "catalog:", + "reka-ui": "catalog:frontend", + "splitpanes": "catalog:", + "vue": "catalog:frontend" + }, + "devDependencies": { + "@iconify-json/ph": "catalog:frontend", + "@vitejs/plugin-vue": "catalog:build", + "get-port-please": "catalog:deps", + "tsx": "catalog:build", + "unocss": "catalog:frontend", + "vite": "catalog:build" + } +} diff --git a/examples/prototype-data-viewer/src/demo-data.ts b/examples/prototype-data-inspector/src/demo-data.ts similarity index 100% rename from examples/prototype-data-viewer/src/demo-data.ts rename to examples/prototype-data-inspector/src/demo-data.ts diff --git a/examples/prototype-data-viewer/src/env.d.ts b/examples/prototype-data-inspector/src/env.d.ts similarity index 74% rename from examples/prototype-data-viewer/src/env.d.ts rename to examples/prototype-data-inspector/src/env.d.ts index a6cf0f0..a00c274 100644 --- a/examples/prototype-data-viewer/src/env.d.ts +++ b/examples/prototype-data-inspector/src/env.d.ts @@ -11,9 +11,16 @@ declare module 'jora' { assertions?: Record boolean)> } export type JoraQueryFn = (data: unknown, context?: unknown) => unknown + export interface JoraSyntax { + parse: (source: string, tolerantMode?: boolean) => unknown + tokenize: (source: string) => unknown + stringify: (ast: unknown) => string + walk: (ast: unknown, visitor: unknown) => void + } export interface Jora { (query: string, options?: JoraQueryOptions): JoraQueryFn setup: (options?: JoraSetupOptions) => (query: string, options?: JoraQueryOptions) => JoraQueryFn + syntax: JoraSyntax version: string } const jora: Jora diff --git a/examples/prototype-data-viewer/src/normalize.ts b/examples/prototype-data-inspector/src/normalize.ts similarity index 100% rename from examples/prototype-data-viewer/src/normalize.ts rename to examples/prototype-data-inspector/src/normalize.ts diff --git a/examples/prototype-data-viewer/src/query-engine.ts b/examples/prototype-data-inspector/src/query-engine.ts similarity index 90% rename from examples/prototype-data-viewer/src/query-engine.ts rename to examples/prototype-data-inspector/src/query-engine.ts index 656de38..283e79e 100644 --- a/examples/prototype-data-viewer/src/query-engine.ts +++ b/examples/prototype-data-inspector/src/query-engine.ts @@ -5,8 +5,8 @@ * suggestions. Custom methods bridge jora's blind spots on live graphs * (Map/Set are opaque to it; the prototype chain is not walked). */ -import type { NormalizeOptions, NormalizeStats } from './normalize' -import type { SuggestItem, SuggestOutcome } from './rpc-contract' +import type { NormalizeOptions } from './normalize' +import type { QueryOutcome, SuggestItem, SuggestOutcome } from './rpc-contract' import jora from 'jora' import { normalize } from './normalize' @@ -50,16 +50,7 @@ const createQuery = jora.setup({ }, }) -export interface QuerySuccess { - ok: true - result: unknown - stats: { queryMs: number, normalize: NormalizeStats } -} -export interface QueryFailure { - ok: false - error: { message: string, name: string } -} -export type QueryOutcome = QuerySuccess | QueryFailure +export type { QueryOutcome } from './rpc-contract' export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome { try { @@ -67,7 +58,9 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti const raw = createQuery(query)(target) const queryMs = Math.round((performance.now() - started) * 100) / 100 const { data, stats } = normalize(raw, options) - return { ok: true, result: data, stats: { queryMs, normalize: stats } } + // The normalizer guarantees plain JSON, so this measures the actual wire payload. + const payloadBytes = new TextEncoder().encode(JSON.stringify(data) ?? '').length + return { ok: true, result: data, stats: { queryMs, normalize: stats, payloadBytes } } } catch (error) { const e = error instanceof Error ? error : new Error(String(error)) diff --git a/examples/prototype-data-inspector/src/registry.ts b/examples/prototype-data-inspector/src/registry.ts new file mode 100644 index 0000000..7988759 --- /dev/null +++ b/examples/prototype-data-inspector/src/registry.ts @@ -0,0 +1,90 @@ +/** + * PROTOTYPE — throwaway code. + * + * The data-source registry under validation: a `WeakMap` keyed by the shared + * `DevframeNodeContext` (the `plugins/git/src/rpc/context.ts` idiom). Every + * plugin's `setup()` and the host's `configureServer` receive the SAME context + * object, so anyone can register sources dynamically — at setup time or later — + * and the data-inspector finds them with no dependency edge beyond this module. + */ +import type { DataSourceMeta } from './rpc-contract' + +export interface DataSourceEntry { + id: string + title: string + description?: string + /** Produce the object to query. Called per query unless `static`. */ + getData: () => unknown + /** Data never changes; `getData()` is called once and memoized (default false). */ + static?: boolean +} + +interface Registry { + entries: Map + /** Memoized `getData()` results for `static: true` sources. */ + staticCache: Map + listeners: Set<() => void> +} + +const registries = new WeakMap() + +function registryFor(ctx: object): Registry { + let registry = registries.get(ctx) + if (!registry) { + registry = { entries: new Map(), staticCache: new Map(), listeners: new Set() } + registries.set(ctx, registry) + } + return registry +} + +function notify(registry: Registry): void { + for (const listener of registry.listeners) + listener() +} + +/** Register (or replace) a source. Returns an unregister function. */ +export function registerDataSource(ctx: object, entry: DataSourceEntry): () => void { + const registry = registryFor(ctx) + registry.entries.set(entry.id, entry) + registry.staticCache.delete(entry.id) + notify(registry) + return () => unregisterDataSource(ctx, entry.id) +} + +export function unregisterDataSource(ctx: object, id: string): void { + const registry = registryFor(ctx) + if (registry.entries.delete(id)) { + registry.staticCache.delete(id) + notify(registry) + } +} + +export function listDataSources(ctx: object): DataSourceMeta[] { + return Array.from(registryFor(ctx).entries.values()).map(entry => ({ + id: entry.id, + title: entry.title, + description: entry.description, + static: entry.static ?? false, + })) +} + +export function getDataSource(ctx: object, id: string): DataSourceEntry | undefined { + return registryFor(ctx).entries.get(id) +} + +/** Resolve a source's data, honoring `static` memoization. */ +export function resolveSourceData(ctx: object, entry: DataSourceEntry): unknown { + if (!entry.static) + return entry.getData() + const registry = registryFor(ctx) + if (!registry.staticCache.has(entry.id)) + registry.staticCache.set(entry.id, entry.getData()) + return registry.staticCache.get(entry.id) +} + +/** Subscribe to registry changes (register/unregister). Returns unsubscribe. */ +export function onDataSourcesChanged(ctx: object, listener: () => void): () => void { + const registry = registryFor(ctx) + registry.listeners.add(listener) + return () => registry.listeners.delete(listener) +} diff --git a/examples/prototype-data-inspector/src/rpc-contract.ts b/examples/prototype-data-inspector/src/rpc-contract.ts new file mode 100644 index 0000000..3cbc298 --- /dev/null +++ b/examples/prototype-data-inspector/src/rpc-contract.ts @@ -0,0 +1,76 @@ +/** + * PROTOTYPE — throwaway code. + * + * Wire types shared by the server RPC functions and the SPA. Types only — + * safe to import from browser code without dragging jora into the bundle. + */ + +/** What the client sees of a registered data source. */ +export interface DataSourceMeta { + id: string + title: string + description?: string + /** Data never changes; the server memoizes `getData()`. */ + static: boolean +} + +/** One completion candidate: replace [from, to) with `value`. */ +export interface SuggestItem { + type: string + from: number + to: number + /** The fragment currently typed in that range. */ + current: string + /** The completion to insert. */ + value: string +} + +export interface SuggestOutcome { + ok: boolean + suggestions: SuggestItem[] + statMs: number + error?: string +} + +export interface NormalizeStatsWire { + nodes: number + refs: number + truncatedDepth: number + truncatedEntries: number + truncatedProps: number + ms: number +} + +export interface QueryStats { + queryMs: number + normalize: NormalizeStatsWire + payloadBytes: number +} + +export type QueryOutcome + = | { ok: true, result: unknown, stats: QueryStats } + | { ok: false, error: { name: string, message: string } } + +/** Where a saved query persists. */ +export type SavedQueryScope = 'user' | 'project' + +export interface SavedQuery { + /** Storage key. Derived from the title when not supplied. */ + id: string + title: string + description?: string + query: string + /** Source the query was authored against. */ + sourceId: string + scope: SavedQueryScope + updatedAt: number +} + +export interface SaveQueryInput { + id?: string + title: string + description?: string + query: string + sourceId: string + scope: SavedQueryScope +} diff --git a/examples/prototype-data-inspector/src/rpc-functions.ts b/examples/prototype-data-inspector/src/rpc-functions.ts new file mode 100644 index 0000000..547ec69 --- /dev/null +++ b/examples/prototype-data-inspector/src/rpc-functions.ts @@ -0,0 +1,84 @@ +/** + * PROTOTYPE — throwaway code. + * + * The RPC surface a real data-inspector plugin would ship, modeled on + * `plugins/inspect` (error envelopes, structured results). All results are + * pre-normalized plain JSON, so `jsonSerializable: true` keeps the wire + * strict and proves wire-safety on every call. + */ +import type { DevframeNodeContext } from 'devframe/types' +import type { SavedQueryScope, SaveQueryInput } from './rpc-contract' +import { createDefineWrapperWithContext } from 'devframe/rpc' +import { runQuery, suggest } from './query-engine' +import { getDataSource, listDataSources, resolveSourceData } from './registry' +import { deleteSavedQuery, listSavedQueries, saveQuery } from './saved-queries' + +const defineRpc = createDefineWrapperWithContext() + +const NS = 'data-inspector' + +export const sourcesFn = defineRpc({ + name: `${NS}:sources`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async () => listDataSources(ctx), + }), +}) + +export const queryFn = defineRpc({ + name: `${NS}:query`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async (sourceId: string, joraQuery: string, options?: { maxDepth?: number, maxEntries?: number }) => { + const source = getDataSource(ctx, sourceId) + if (!source) + return { ok: false as const, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } } + return runQuery(resolveSourceData(ctx, source), joraQuery, options) + }, + }), +}) + +export const suggestFn = defineRpc({ + name: `${NS}:suggest`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async (sourceId: string, joraQuery: string, pos: number) => { + const source = getDataSource(ctx, sourceId) + if (!source) + return { ok: false, suggestions: [], statMs: 0, error: `No data source "${sourceId}"` } + return suggest(resolveSourceData(ctx, source), joraQuery, pos) + }, + }), +}) + +export const savedListFn = defineRpc({ + name: `${NS}:saved:list`, + type: 'query', + jsonSerializable: true, + setup: ctx => ({ + handler: async () => listSavedQueries(ctx), + }), +}) + +export const savedSaveFn = defineRpc({ + name: `${NS}:saved:save`, + type: 'action', + jsonSerializable: true, + setup: ctx => ({ + handler: async (input: SaveQueryInput) => saveQuery(ctx, input), + }), +}) + +export const savedDeleteFn = defineRpc({ + name: `${NS}:saved:delete`, + type: 'action', + jsonSerializable: true, + setup: ctx => ({ + handler: async (id: string, scope: SavedQueryScope) => deleteSavedQuery(ctx, id, scope), + }), +}) + +export const allRpcFunctions = [sourcesFn, queryFn, suggestFn, savedListFn, savedSaveFn, savedDeleteFn] diff --git a/examples/prototype-data-inspector/src/saved-queries.ts b/examples/prototype-data-inspector/src/saved-queries.ts new file mode 100644 index 0000000..fed6f58 --- /dev/null +++ b/examples/prototype-data-inspector/src/saved-queries.ts @@ -0,0 +1,100 @@ +/** + * PROTOTYPE — throwaway code. + * + * Saved-query persistence, id-keyed, in two scopes: + * - `user` -> `/node_modules/.data-inspector/queries.json` + * (per-checkout, invisible to git) + * - `project` -> `/.devframe/data-inspector/queries.json` + * (committable, shared with the team) + * + * Backed by devframe's `createStorage` (debounced atomic JSON writes). + */ +import type { DevframeNodeContext } from 'devframe/types' +import type { SavedQuery, SavedQueryScope, SaveQueryInput } from './rpc-contract' +import { join } from 'node:path' +import { createStorage } from 'devframe/node' + +interface QueriesFile { + queries: Record> +} + +type Store = ReturnType> + +const stores = new WeakMap>() + +const SCOPE_PATHS: Record = { + user: 'node_modules/.data-inspector/queries.json', + project: '.devframe/data-inspector/queries.json', +} + +function storesFor(ctx: DevframeNodeContext): Record { + let byScope = stores.get(ctx) + if (!byScope) { + byScope = { + user: createStorage({ + filepath: join(ctx.workspaceRoot, SCOPE_PATHS.user), + initialValue: { queries: {} }, + }), + project: createStorage({ + filepath: join(ctx.workspaceRoot, SCOPE_PATHS.project), + initialValue: { queries: {} }, + }), + } + stores.set(ctx, byScope) + } + return byScope +} + +function slugify(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + || `query-${Date.now()}` +} + +export function listSavedQueries(ctx: DevframeNodeContext): SavedQuery[] { + const byScope = storesFor(ctx) + const out: SavedQuery[] = [] + for (const scope of ['project', 'user'] as const) { + for (const entry of Object.values(byScope[scope].value().queries)) + out.push({ ...entry, scope }) + } + return out.sort((a, b) => b.updatedAt - a.updatedAt) +} + +export function saveQuery(ctx: DevframeNodeContext, input: SaveQueryInput): SavedQuery { + const byScope = storesFor(ctx) + const id = input.id ?? slugify(input.title) + const record: Omit = { + id, + title: input.title, + description: input.description || undefined, + query: input.query, + sourceId: input.sourceId, + updatedAt: Date.now(), + } + byScope[input.scope].mutate((draft) => { + draft.queries[id] = record + }) + // The id is the storage key across both scopes — saving into one scope + // moves the query there rather than leaving a stale twin behind. + const other: SavedQueryScope = input.scope === 'user' ? 'project' : 'user' + if (byScope[other].value().queries[id]) { + byScope[other].mutate((draft) => { + delete draft.queries[id] + }) + } + return { ...record, scope: input.scope } +} + +export function deleteSavedQuery(ctx: DevframeNodeContext, id: string, scope: SavedQueryScope): boolean { + const byScope = storesFor(ctx) + const exists = !!byScope[scope].value().queries[id] + if (exists) { + byScope[scope].mutate((draft) => { + delete draft.queries[id] + }) + } + return exists +} diff --git a/examples/prototype-data-inspector/src/spa/App.vue b/examples/prototype-data-inspector/src/spa/App.vue new file mode 100644 index 0000000..b3e0500 --- /dev/null +++ b/examples/prototype-data-inspector/src/spa/App.vue @@ -0,0 +1,118 @@ + + + diff --git a/examples/prototype-data-inspector/src/spa/components/QueryEditor.vue b/examples/prototype-data-inspector/src/spa/components/QueryEditor.vue new file mode 100644 index 0000000..4a8a112 --- /dev/null +++ b/examples/prototype-data-inspector/src/spa/components/QueryEditor.vue @@ -0,0 +1,115 @@ + + +