diff --git a/packages/json-render-ui/package.json b/packages/json-render-ui/package.json index a544a272..735cd267 100644 --- a/packages/json-render-ui/package.json +++ b/packages/json-render-ui/package.json @@ -33,7 +33,7 @@ "build": "tsdown && vite build --config src/spa/vite.config.ts", "watch": "tsdown --watch", "typecheck": "tsc --noEmit", - "storybook": "storybook dev -p 6014 --host 0.0.0.0", + "storybook": "storybook dev -p 6014", "build-storybook": "storybook build", "test": "vitest run", "prepack": "pnpm run build" diff --git a/plugins/a11y/package.json b/plugins/a11y/package.json index 846c4c99..e59e4213 100644 --- a/plugins/a11y/package.json +++ b/plugins/a11y/package.json @@ -43,7 +43,7 @@ "build:inject": "vite build --config src/inject/vite.config.ts", "watch": "tsdown --watch", "dev": "node bin.mjs", - "storybook": "storybook dev -p 6015 --host 0.0.0.0", + "storybook": "storybook dev -p 6015", "build-storybook": "storybook build", "cli:build": "node bin.mjs build --out-dir dist/static", "demo": "node demo/server.mjs", diff --git a/plugins/code-server/package.json b/plugins/code-server/package.json index 67dad509..db7b0140 100644 --- a/plugins/code-server/package.json +++ b/plugins/code-server/package.json @@ -43,8 +43,8 @@ "build": "tsdown && vite build --config src/spa/vite.config.ts", "watch": "tsdown --watch", "typecheck": "tsc --noEmit", - "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", - "storybook": "storybook dev -p 6013 --host 0.0.0.0", + "dev": "vite --config src/spa/vite.config.ts", + "storybook": "storybook dev -p 6013", "build-storybook": "storybook build", "test": "vitest run", "prepack": "pnpm run build" diff --git a/plugins/data-inspector/README.md b/plugins/data-inspector/README.md index 2a0defcb..3dbdc76a 100644 --- a/plugins/data-inspector/README.md +++ b/plugins/data-inspector/README.md @@ -26,6 +26,27 @@ ctx.services.whenAvailable('devframes:plugin:data-inspector:sources', (sources) }) ``` +## Writable sources + +Sources opt into live edits with `writable: true`: on the root view (`$`), every value grows an edit affordance that opens a side panel — set a value (string / number / boolean / null / undefined / JSON), add or delete entries, rename keys — and the mutation is applied **in place to the live object** through the `write` RPC. Read-only stays the default; `static: true` sources are memoized snapshots and always stay read-only (declaring both reports `DP_DATA_INSPECTOR_0004`). + +`registerDataSource` returns a handle; call `notifyChanged()` whenever the data changes outside the inspector so connected views re-run, or hand the plugin a bridge to the source's own change signal via `subscribe`: + +```ts +const handle = registerDataSource({ + id: 'my-plugin:state', + title: 'My plugin state', + data: () => store, + writable: true, // opt-in: the inspector may mutate the live object + subscribe: (notify) => { // optional: push the source's own change signal + store.on('change', notify) + return () => store.off('change', notify) + }, +}) + +handle.notifyChanged() // or notify imperatively +``` + ## Mount ```ts diff --git a/plugins/data-inspector/package.json b/plugins/data-inspector/package.json index baff0d9c..cfa315f2 100644 --- a/plugins/data-inspector/package.json +++ b/plugins/data-inspector/package.json @@ -41,9 +41,9 @@ ], "scripts": { "build": "tsdown && vite build --config src/spa/vite.config.ts", - "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", + "dev": "vite --config src/spa/vite.config.ts", "watch": "tsdown --watch", - "storybook": "storybook dev -p 6016 --host 0.0.0.0", + "storybook": "storybook dev -p 6016", "build-storybook": "storybook build", "prepack": "pnpm build", "test": "vitest run", diff --git a/plugins/data-inspector/src/engine/contract.ts b/plugins/data-inspector/src/engine/contract.ts index 92979bf1..0c4c9251 100644 --- a/plugins/data-inspector/src/engine/contract.ts +++ b/plugins/data-inspector/src/engine/contract.ts @@ -47,10 +47,43 @@ export interface DataSourceMeta { icon?: string /** Data never changes; the server memoizes the resolved value. */ static: boolean + /** The source opted into live edits through the `write` RPC. */ + writable: boolean /** Suggested queries provided by the source (shown read-only). */ queries?: Query[] } +/** + * A value carried inside a write request. JSON can't express `undefined`, + * so the payload is discriminated instead of raw. + */ +export type WriteValue + = | { kind: 'json', value: unknown } + | { kind: 'undefined' } + +/** + * One mutation of a writable source's live object. Ops are container-generic: + * the server resolves the path and dispatches on what it finds there + * (object / array / Map / Set). + * + * - `set` — replace the value at `path`. + * - `delete` — remove the node at `path` from its container. + * - `add` — `path` addresses the CONTAINER; insert `key`/`value` + * (objects and Maps need `key`; arrays take an optional index + * `key` to splice at, else append; Sets take just `value`). + * - `rename` — re-key the node at `path` under `key`, atomically + * (objects and Maps; the renamed key lands last). + */ +export type WriteRequest + = | { op: 'set', path: NodePath, value: WriteValue } + | { op: 'delete', path: NodePath } + | { op: 'add', path: NodePath, key?: WriteValue, value: WriteValue } + | { op: 'rename', path: NodePath, key: WriteValue } + +export type WriteOutcome + = | { ok: true } + | { ok: false, error: { name: string, message: string } } + /** One completion candidate: replace [from, to) with `value`. */ export interface SuggestItem { type: string diff --git a/plugins/data-inspector/src/engine/index.ts b/plugins/data-inspector/src/engine/index.ts index ed367829..5a832622 100644 --- a/plugins/data-inspector/src/engine/index.ts +++ b/plugins/data-inspector/src/engine/index.ts @@ -2,3 +2,4 @@ export * from './contract' export * from './normalize' export * from './query-engine' export * from './skeleton' +export * from './write' diff --git a/plugins/data-inspector/src/engine/write.ts b/plugins/data-inspector/src/engine/write.ts new file mode 100644 index 00000000..423b44d2 --- /dev/null +++ b/plugins/data-inspector/src/engine/write.ts @@ -0,0 +1,276 @@ +/** + * The write applier: mutate a live object graph in place along a `NodePath`. + * + * Ops are container-generic on the wire (`set` / `delete` / `add` / `rename`); + * this module resolves the path with the same descent semantics as the + * normalizer's `navigate` (filter options shift array indices) and dispatches + * on the container it finds — plain object, array, Map, or Set. Every failure + * returns a named error outcome; nothing here throws. + */ +import type { NodePath, PathSegment, WriteOutcome, WriteRequest, WriteValue } from './contract' +import { navigate } from './normalize' + +export interface WriteApplyOptions { + /** Must match the client's view so `['i', n]` indices line up. */ + excludeFunctions?: boolean +} + +class WriteError extends Error { + constructor(name: string, message: string) { + super(message) + this.name = name + } +} + +/** Decode a discriminated wire value into the raw JS value to write. */ +function decode(value: WriteValue): unknown { + return value.kind === 'undefined' ? undefined : value.value +} + +/** A JSON-expressible Map key / Set element (string-coerced object keys aside). */ +function decodeKey(value: WriteValue | undefined, op: string): unknown { + if (!value) + throw new WriteError('MissingKey', `"${op}" needs a key`) + return decode(value) +} + +/** Map a filtered array index back onto the real one (mirrors `navigate`). */ +function realIndex(arr: unknown[], filtered: number, opts: WriteApplyOptions): number { + if (!opts.excludeFunctions) + return filtered + let seen = -1 + for (let i = 0; i < arr.length; i++) { + if (typeof arr[i] === 'function') + continue + seen++ + if (seen === filtered) + return i + } + return -1 +} + +function entryAt(map: Map, index: number): [unknown, unknown] { + const entry = [...map.entries()][index] + if (!entry) + throw new WriteError('PathNotFound', `Map entry ${index} does not exist`) + return entry +} + +function elementAt(set: Set, index: number): unknown { + const values = [...set] + if (index < 0 || index >= values.length) + throw new WriteError('PathNotFound', `Set element ${index} does not exist`) + return values[index] +} + +function assertMutableObject(target: object): void { + if (Object.isFrozen(target)) + throw new WriteError('FrozenTarget', 'the target object is frozen') +} + +/** Resolve the container a path's final segment applies to. */ +function resolveParent(root: unknown, path: NodePath, opts: WriteApplyOptions): object { + const parent = navigate(root, path.slice(0, -1), opts) + if (parent === null || typeof parent !== 'object') + throw new WriteError('PathNotFound', 'the path does not resolve to a container') + return parent +} + +function setAt(parent: object, seg: PathSegment, value: unknown, opts: WriteApplyOptions): void { + const [kind, at] = seg + switch (kind) { + case 'k': { + if (parent instanceof Map) { + parent.set(at, value) + return + } + assertMutableObject(parent) + const key = at as string + const desc = Object.getOwnPropertyDescriptor(parent, key) + if (desc && !desc.writable && !desc.set) + throw new WriteError('ReadonlyProperty', `property "${key}" has no setter`) + const record = parent as Record + record[key] = value + return + } + case 'i': { + if (!Array.isArray(parent)) + throw new WriteError('WrongContainer', 'an index step needs an array') + const index = realIndex(parent, at as number, opts) + if (index < 0 || index >= parent.length) + throw new WriteError('PathNotFound', `array index ${at} does not exist`) + parent[index] = value + return + } + case 's': { + if (!(parent instanceof Set)) + throw new WriteError('WrongContainer', 'a set step needs a Set') + // A Set has no positional assignment: replace = delete + add. + parent.delete(elementAt(parent, at as number)) + parent.add(value) + return + } + case 'mk': { + if (!(parent instanceof Map)) + throw new WriteError('WrongContainer', 'a map-key step needs a Map') + const [oldKey, entryValue] = entryAt(parent, at as number) + parent.delete(oldKey) + parent.set(value, entryValue) + return + } + case 'mv': { + if (!(parent instanceof Map)) + throw new WriteError('WrongContainer', 'a map-value step needs a Map') + const [key] = entryAt(parent, at as number) + parent.set(key, value) + } + } +} + +function deleteAt(parent: object, seg: PathSegment, opts: WriteApplyOptions): void { + const [kind, at] = seg + switch (kind) { + case 'k': { + if (parent instanceof Map) { + if (!parent.delete(at)) + throw new WriteError('PathNotFound', `Map key "${at}" does not exist`) + return + } + assertMutableObject(parent) + const key = at as string + if (!Object.hasOwn(parent, key)) + throw new WriteError('PathNotFound', `property "${key}" does not exist`) + if (!delete (parent as Record)[key]) + throw new WriteError('ReadonlyProperty', `property "${key}" cannot be deleted`) + return + } + case 'i': { + if (!Array.isArray(parent)) + throw new WriteError('WrongContainer', 'an index step needs an array') + const index = realIndex(parent, at as number, opts) + if (index < 0 || index >= parent.length) + throw new WriteError('PathNotFound', `array index ${at} does not exist`) + parent.splice(index, 1) + return + } + case 's': { + if (!(parent instanceof Set)) + throw new WriteError('WrongContainer', 'a set step needs a Set') + parent.delete(elementAt(parent, at as number)) + return + } + // Deleting either half of a Map entry removes the entry. + case 'mk': + case 'mv': { + if (!(parent instanceof Map)) + throw new WriteError('WrongContainer', 'a map-entry step needs a Map') + const [key] = entryAt(parent, at as number) + parent.delete(key) + } + } +} + +function addTo(container: object, key: WriteValue | undefined, value: unknown, opts: WriteApplyOptions): void { + if (container instanceof Map) { + container.set(decodeKey(key, 'add'), value) + return + } + if (container instanceof Set) { + container.add(value) + return + } + if (Array.isArray(container)) { + const rawIndex = key ? decode(key) : undefined + if (rawIndex === undefined) { + container.push(value) + return + } + if (typeof rawIndex !== 'number' || !Number.isInteger(rawIndex)) + throw new WriteError('InvalidKey', 'an array insertion index must be an integer') + const index = realIndex(container, rawIndex, opts) + container.splice(index < 0 ? container.length : index, 0, value) + return + } + assertMutableObject(container) + const propKey = decodeKey(key, 'add') + if (typeof propKey !== 'string') + throw new WriteError('InvalidKey', 'an object property key must be a string') + const record = container as Record + record[propKey] = value +} + +function renameAt(parent: object, seg: PathSegment, newKey: unknown): void { + const [kind, at] = seg + if (kind === 'k' && parent instanceof Map) { + if (!parent.has(at)) + throw new WriteError('PathNotFound', `Map key "${at}" does not exist`) + if (newKey === at) + return + const value = parent.get(at) + parent.delete(at) + parent.set(newKey, value) + return + } + if (kind === 'k') { + assertMutableObject(parent) + const key = at as string + if (!Object.hasOwn(parent, key)) + throw new WriteError('PathNotFound', `property "${key}" does not exist`) + if (typeof newKey !== 'string') + throw new WriteError('InvalidKey', 'an object property key must be a string') + if (newKey === key) + return + const value = (parent as Record)[key] + delete (parent as Record)[key] + ;(parent as Record)[newKey] = value + return + } + if (kind === 'mk' || kind === 'mv') { + if (!(parent instanceof Map)) + throw new WriteError('WrongContainer', 'a map-entry step needs a Map') + const [oldKey, value] = entryAt(parent, at as number) + if (newKey === oldKey) + return + parent.delete(oldKey) + parent.set(newKey, value) + return + } + throw new WriteError('WrongContainer', 'only keyed entries (objects, Maps) can be renamed') +} + +/** + * Apply one write request to a live root object. Mutates in place; + * returns a named error outcome instead of throwing. + */ +export function applyWrite(root: unknown, request: WriteRequest, options: WriteApplyOptions = {}): WriteOutcome { + try { + if (request.op === 'add') { + // `add` addresses the container itself, not a node inside it. + const container = navigate(root, request.path, options) + if (container === null || typeof container !== 'object') + throw new WriteError('PathNotFound', 'the path does not resolve to a container') + addTo(container, request.key, decode(request.value), options) + return { ok: true } + } + if (request.path.length === 0) + throw new WriteError('InvalidPath', 'the root itself cannot be replaced, deleted, or renamed') + const parent = resolveParent(root, request.path, options) + const seg = request.path[request.path.length - 1] + switch (request.op) { + case 'set': + setAt(parent, seg, decode(request.value), options) + break + case 'delete': + deleteAt(parent, seg, options) + break + case 'rename': + renameAt(parent, seg, decode(request.key)) + break + } + return { ok: true } + } + catch (error) { + const e = error instanceof Error ? error : new Error(String(error)) + return { ok: false, error: { name: e.name, message: e.message } } + } +} diff --git a/plugins/data-inspector/src/index.ts b/plugins/data-inspector/src/index.ts index 28050ea1..55ad27c0 100644 --- a/plugins/data-inspector/src/index.ts +++ b/plugins/data-inspector/src/index.ts @@ -85,5 +85,5 @@ export function createDataInspectorDevframe(options: DataInspectorDevframeOption } export default createDataInspectorDevframe() -export type { DataSourceEntry, DataSourcesService } from './registry/index' +export type { DataSourceEntry, DataSourceHandle, DataSourcesService } from './registry/index' export { DATA_SOURCES_SERVICE_ID, registerDataSource } from './registry/index' diff --git a/plugins/data-inspector/src/node/diagnostics.ts b/plugins/data-inspector/src/node/diagnostics.ts index fd6d4806..3736944d 100644 --- a/plugins/data-inspector/src/node/diagnostics.ts +++ b/plugins/data-inspector/src/node/diagnostics.ts @@ -1,4 +1,16 @@ +import type { Diagnostic } from 'nostics' +import { colors as c } from 'devframe/utils/colors' import { defineDiagnostics } from 'nostics' +import { ansiFormatter } from 'nostics/formatters/ansi' + +const formatAnsi = ansiFormatter(c) + +interface ReporterOptions { method?: 'log' | 'warn' | 'error' } + +function reporter(d: Diagnostic, { method = 'warn' }: ReporterOptions = {}): void { + // eslint-disable-next-line no-console + console[method](formatAnsi(d)) +} /** * Structured diagnostics for `@devframes/plugin-data-inspector`. Node-side @@ -7,6 +19,7 @@ import { defineDiagnostics } from 'nostics' */ export const diagnostics = defineDiagnostics({ docsBase: 'https://devfra.me/errors', + reporters: [reporter], codes: { DP_DATA_INSPECTOR_0001: { why: (p: { id: string }) => `No data source is registered under "${p.id}".`, @@ -20,5 +33,9 @@ export const diagnostics = defineDiagnostics({ why: (p: { filepath: string, message: string }) => `Failed to load data file "${p.filepath}": ${p.message}`, fix: 'Check that the file exists and contains valid JSON (or one JSON value per line for .jsonl/.ndjson).', }, + DP_DATA_INSPECTOR_0004: { + why: (p: { id: string }) => `Data source "${p.id}" declares both \`static: true\` and \`writable: true\`; a memoized snapshot is read-only.`, + fix: 'Drop `static: true` to make the live object writable, or drop `writable: true` to keep the memoized snapshot. The source stays read-only.', + }, }, }) diff --git a/plugins/data-inspector/src/node/example-source.ts b/plugins/data-inspector/src/node/example-source.ts index 3cc953a3..82408965 100644 --- a/plugins/data-inspector/src/node/example-source.ts +++ b/plugins/data-inspector/src/node/example-source.ts @@ -105,6 +105,9 @@ export function createExampleDataSource(ctx?: DevframeNodeContext): DataSourceEn description: 'Devframe context, OS and process info, plus a playground graph.', icon: 'i-ph:flask-duotone', data: () => data ??= createExampleData(ctx), + // The factory memoizes, so live edits stick between queries — the + // playground doubles as a demo of writable sources. + writable: true, queries: [ ...(ctx ? [{ diff --git a/plugins/data-inspector/src/node/index.ts b/plugins/data-inspector/src/node/index.ts index e56b5f5c..9e0d4a28 100644 --- a/plugins/data-inspector/src/node/index.ts +++ b/plugins/data-inspector/src/node/index.ts @@ -1,11 +1,18 @@ import type { DevframeNodeContext } from 'devframe/types' -import { createDataSourcesService, DATA_SOURCES_SERVICE_ID, getDataSource, onDataSourcesChanged, registerDataSource } from '../registry/index' +import { createDataSourcesService, DATA_SOURCES_SERVICE_ID, getDataSource, onDataSourceDataChanged, onDataSourcesChanged, registerDataSource } from '../registry/index' import { serverFunctions } from '../rpc/index' import { createExampleDataSource, EXAMPLE_SOURCE_ID } from './example-source' /** Broadcast whenever the source registry changes (register/unregister). */ export const SOURCES_CHANGED_EVENT = 'devframes:plugin:data-inspector:sources:changed' +/** + * Broadcast whenever a source's DATA changes — a successful `write`, a + * `notifyChanged()` handle call, or a source's own `subscribe` bridge. + * Carries the source id so clients refresh only the affected view. + */ +export const DATA_CHANGED_EVENT = 'devframes:plugin:data-inspector:data:changed' + export interface SetupDataInspectorOptions { /** Register the built-in example source (default `true`). */ exampleSource?: boolean @@ -34,7 +41,11 @@ export function setupDataInspector(ctx: DevframeNodeContext, options: SetupDataI registerDataSource(createExampleDataSource(ctx)) onDataSourcesChanged(() => { - ctx.rpc.broadcast(SOURCES_CHANGED_EVENT as never) + void ctx.rpc.broadcast({ method: SOURCES_CHANGED_EVENT, args: [] } as never) + }) + + onDataSourceDataChanged((sourceId) => { + void ctx.rpc.broadcast({ method: DATA_CHANGED_EVENT, args: [sourceId] } as never) }) } diff --git a/plugins/data-inspector/src/registry/index.ts b/plugins/data-inspector/src/registry/index.ts index b054f970..613a8d5d 100644 --- a/plugins/data-inspector/src/registry/index.ts +++ b/plugins/data-inspector/src/registry/index.ts @@ -27,6 +27,7 @@ * ``` */ import type { DataSourceMeta, Query } from '../engine/contract' +import { diagnostics } from '../node/diagnostics' export interface DataSourceEntry { /** Unique id — namespace it with your plugin id (`my-plugin:thing`). */ @@ -46,17 +47,40 @@ export interface DataSourceEntry { * value is memoized (default `false`). */ static?: boolean + /** + * Opt this source into live edits (default `false`): connected inspectors + * may mutate the resolved object in place through the `write` RPC. + * Contradicts `static: true` — a memoized snapshot is read-only, so the + * combination reports a diagnostic and stays read-only. + */ + writable?: boolean + /** + * Bridge the source's own change signal: called with a `notify` function + * that broadcasts a `data:changed` event to connected clients (same signal + * a successful `write` emits). Return a dispose function to unhook; it runs + * when the source is unregistered or replaced. + */ + subscribe?: (notify: () => void) => (() => void) | void /** Suggested queries, surfaced read-only next to saved ones. */ queries?: Query[] } +/** Handle returned by `registerDataSource` — notify clients or unregister. */ +export interface DataSourceHandle { + /** Broadcast that this source's data changed, so connected UIs re-run. */ + notifyChanged: () => void + /** Remove this source from the registry. */ + unregister: () => void +} + /** The service provided on `ctx.services` (same store as the module API). */ export interface DataSourcesService { - register: (entry: DataSourceEntry) => () => void + register: (entry: DataSourceEntry) => DataSourceHandle unregister: (id: string) => void list: () => DataSourceMeta[] get: (id: string) => DataSourceEntry | undefined onChanged: (listener: () => void) => () => void + onDataChanged: (listener: (sourceId: string) => void) => () => void } /** Id under which the registry is provided on `ctx.services`. */ @@ -72,6 +96,9 @@ interface RegistryStore { entries: Map staticCache: Map> listeners: Set<() => void> + dataListeners: Set<(sourceId: string) => void> + /** Dispose functions for wired `entry.subscribe` hooks, per source id. */ + subscriptions: Map void> } const GLOBAL_KEY = Symbol.for('devframes:plugin:data-inspector:registry@1') @@ -80,9 +107,12 @@ function store(): RegistryStore { const holder = globalThis as Record let value = holder[GLOBAL_KEY] as RegistryStore | undefined if (!value) { - value = { entries: new Map(), staticCache: new Map(), listeners: new Set() } + value = { entries: new Map(), staticCache: new Map(), listeners: new Set(), dataListeners: new Set(), subscriptions: new Map() } holder[GLOBAL_KEY] = value } + // Stores minted by an older copy of this module may predate these fields. + value.dataListeners ??= new Set() + value.subscriptions ??= new Map() return value } @@ -91,17 +121,53 @@ function notify(registry: RegistryStore): void { listener() } -/** Register (or replace) a data source. Returns an unregister function. */ -export function registerDataSource(entry: DataSourceEntry): () => void { +/** Broadcast that a source's data changed to every data-changed listener. */ +export function notifyDataSourceChanged(id: string): void { + const registry = store() + for (const listener of registry.dataListeners) + listener(id) +} + +/** Effective writability of an entry: opt-in, and never on a static source. */ +export function isWritableEntry(entry: DataSourceEntry): boolean { + return (entry.writable ?? false) && !entry.static +} + +function unwireSubscription(registry: RegistryStore, id: string): void { + const dispose = registry.subscriptions.get(id) + if (dispose) { + registry.subscriptions.delete(id) + try { + dispose() + } + catch {} + } +} + +/** Register (or replace) a data source. Returns a handle to the registration. */ +export function registerDataSource(entry: DataSourceEntry): DataSourceHandle { const registry = store() + if (entry.static && entry.writable) + diagnostics.DP_DATA_INSPECTOR_0004({ id: entry.id }) + unwireSubscription(registry, entry.id) registry.entries.set(entry.id, entry) registry.staticCache.delete(entry.id) + const notifyChanged = (): void => notifyDataSourceChanged(entry.id) + if (entry.subscribe) { + const dispose = entry.subscribe(notifyChanged) + if (dispose) + registry.subscriptions.set(entry.id, dispose) + } notify(registry) - return () => unregisterDataSource(entry.id) + return { + notifyChanged, + unregister: () => unregisterDataSource(entry.id), + } } export function unregisterDataSource(id: string): void { const registry = store() + unwireSubscription(registry, id) if (registry.entries.delete(id)) { registry.staticCache.delete(id) notify(registry) @@ -115,6 +181,7 @@ export function listDataSources(): DataSourceMeta[] { description: entry.description, icon: entry.icon, static: entry.static ?? false, + writable: isWritableEntry(entry), queries: entry.queries, })) } @@ -150,9 +217,23 @@ export function onDataSourcesChanged(listener: () => void): () => void { } } +/** + * Subscribe to data-changed notifications (writes, `notifyChanged` handles, + * `subscribe` bridges). The listener receives the source id. + */ +export function onDataSourceDataChanged(listener: (sourceId: string) => void): () => void { + const registry = store() + registry.dataListeners.add(listener) + return () => { + registry.dataListeners.delete(listener) + } +} + /** Drop every registration and cache — test isolation helper. */ export function resetDataSources(): void { const registry = store() + for (const id of [...registry.subscriptions.keys()]) + unwireSubscription(registry, id) registry.entries.clear() registry.staticCache.clear() notify(registry) @@ -166,5 +247,6 @@ export function createDataSourcesService(): DataSourcesService { list: listDataSources, get: getDataSource, onChanged: onDataSourcesChanged, + onDataChanged: onDataSourceDataChanged, } } diff --git a/plugins/data-inspector/src/rpc/functions/write.ts b/plugins/data-inspector/src/rpc/functions/write.ts new file mode 100644 index 00000000..5dfa84a4 --- /dev/null +++ b/plugins/data-inspector/src/rpc/functions/write.ts @@ -0,0 +1,35 @@ +import type { WriteOutcome, WriteRequest } from '../../engine/contract' +import type { WriteApplyOptions } from '../../engine/write' +import { applyWrite } from '../../engine/write' +import { getDataSource, isWritableEntry, notifyDataSourceChanged, resolveSourceData } from '../../registry/index' +import { defineDataInspectorRpc, NS } from './_define' + +/** + * Mutate a writable source's live object in place. Only sources that opted + * in with `writable: true` accept writes; the path must address the source + * root (identity view), and the same filter options the client viewed with + * must be threaded through so array indices line up. Broadcasts + * `data:changed` on success so every connected client refreshes. + */ +export const write = defineDataInspectorRpc({ + name: `${NS}:write`, + type: 'action', + jsonSerializable: true, + setup: () => ({ + handler: async ( + sourceId: string, + request: WriteRequest, + options?: WriteApplyOptions, + ): Promise => { + const source = getDataSource(sourceId) + if (!source) + return { ok: false, error: { name: 'UnknownSource', message: `No data source "${sourceId}"` } } + if (!isWritableEntry(source)) + return { ok: false, error: { name: 'ReadonlySource', message: `Data source "${sourceId}" is not writable` } } + const outcome = applyWrite(await resolveSourceData(source), request, options) + if (outcome.ok) + notifyDataSourceChanged(sourceId) + return outcome + }, + }), +}) diff --git a/plugins/data-inspector/src/rpc/index.ts b/plugins/data-inspector/src/rpc/index.ts index ecfa5097..2f362b01 100644 --- a/plugins/data-inspector/src/rpc/index.ts +++ b/plugins/data-inspector/src/rpc/index.ts @@ -5,6 +5,7 @@ import { savedDelete, savedList, savedSave } from './functions/saved' import { skeleton } from './functions/skeleton' import { sources } from './functions/sources' import { suggest } from './functions/suggest' +import { write } from './functions/write' /** * The RPC functions registered by the data-inspector plugin. @@ -19,6 +20,7 @@ export const serverFunctions = [ savedList, savedSave, savedDelete, + write, ] as const declare module 'devframe' { diff --git a/plugins/data-inspector/src/spa/App.vue b/plugins/data-inspector/src/spa/App.vue index 59532610..6071533e 100644 --- a/plugins/data-inspector/src/spa/App.vue +++ b/plugins/data-inspector/src/spa/App.vue @@ -34,6 +34,8 @@ onMounted(async () => { await Promise.all([wb.loadSources(), savedApi.refresh()]) // Sources registered/unregistered after boot refresh the picker live. backend().onSourcesChanged(() => void wb.loadSources()) + // Data changes (writes, server-side notifications) refresh the active view. + backend().onDataChanged(sourceId => wb.handleDataChanged(sourceId)) // In a hub, another dock can deep-link straight to a source via dock // activation (`hub:docks:activate` with `params.sourceId`); focus it. void onDockActivation('devframes:plugin:data-inspector', id => wb.focusSource(id)) @@ -86,6 +88,8 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved :error="wb.serverError.value" :running="wb.running.value" :last-run-at="wb.lastRunAt.value" + :can-edit="wb.canEdit.value" + :edit-hint="wb.editHint.value" :expand="wb.expandNode" @rerun="wb.runNow()" @query-subquery="wb.applySubquery($event)" diff --git a/plugins/data-inspector/src/spa/components/DataSourcePanel.vue b/plugins/data-inspector/src/spa/components/DataSourcePanel.vue index 99e4226d..bfa0d37d 100644 --- a/plugins/data-inspector/src/spa/components/DataSourcePanel.vue +++ b/plugins/data-inspector/src/spa/components/DataSourcePanel.vue @@ -14,7 +14,7 @@ const showDetails = ref(false)