From 4431942c27dbe17ef7222da532a50920b073181d Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 22 Jun 2026 16:11:48 +0300 Subject: [PATCH 1/9] Delete fixture mode; add dash-books canon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the @modernrelay/notebook-fixture package and all fixture-mode wiring — the notebook now runs only against omnigraph-server via the SDK (no in-memory mock graph). Adds dash-books-canon.md as the project's source of truth (concept, key modules, current state, target shape) and refreshes CLAUDE/README/AGENTS to the server-only model. - delete packages/fixture; drop the notebook-fixture dep from tui/web/cli - remove top-level notebook.fixture selection + the example fixtures; tui/web/cli now build a ServerSource only - spec keeps the cell-level structured query DSL as the interim server query path (slated to become predefined-query `ref` per the canon) - docs: "server-backed runtime"; package map drops the fixture row Verified: pnpm -r build/typecheck/test green (62 tests); CLI validate/schema/catalog smoke-tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 29 +- CLAUDE.md | 29 +- README.md | 14 +- dash-books-canon.md | 284 ++++++++++++++++++ examples/company.notebook.yaml | 198 ------------ examples/fixtures/company-context.json | 236 --------------- packages/cli/package.json | 1 - packages/cli/src/commands/validate.ts | 8 +- packages/cli/src/serve.ts | 50 +-- packages/cli/src/source.ts | 44 +-- packages/client/src/translate.ts | 7 +- packages/core/src/catalog/index.ts | 4 +- .../core/src/catalog/lenses/action_list.ts | 4 +- packages/core/src/spec/index.test.ts | 8 +- packages/core/src/spec/index.ts | 18 +- packages/fixture/package.json | 33 -- packages/fixture/src/index.ts | 23 -- packages/fixture/src/loader.test.ts | 63 ---- packages/fixture/src/loader.ts | 13 - packages/fixture/src/runner.test.ts | 170 ----------- packages/fixture/src/runner.ts | 264 ---------------- packages/fixture/src/source.test.ts | 66 ---- packages/fixture/src/source.ts | 83 ----- packages/fixture/src/validator.ts | 64 ---- packages/fixture/tsconfig.json | 9 - packages/tui/package.json | 1 - packages/tui/src/index.tsx | 27 +- packages/web/package.json | 1 - packages/web/src/App.test.ts | 31 +- packages/web/src/App.tsx | 2 +- packages/web/src/config.ts | 33 +- pnpm-lock.yaml | 22 -- 32 files changed, 378 insertions(+), 1461 deletions(-) create mode 100644 dash-books-canon.md delete mode 100644 examples/company.notebook.yaml delete mode 100644 examples/fixtures/company-context.json delete mode 100644 packages/fixture/package.json delete mode 100644 packages/fixture/src/index.ts delete mode 100644 packages/fixture/src/loader.test.ts delete mode 100644 packages/fixture/src/loader.ts delete mode 100644 packages/fixture/src/runner.test.ts delete mode 100644 packages/fixture/src/runner.ts delete mode 100644 packages/fixture/src/source.test.ts delete mode 100644 packages/fixture/src/source.ts delete mode 100644 packages/fixture/src/validator.ts delete mode 100644 packages/fixture/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 262a441..2dbff73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,12 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t Turn an OmniGraph graph database into a **read-and-act dashboard you describe in one YAML file** — rendered identically in a terminal and a browser. -Normally you inspect a graph by writing queries and reading JSON, or by building a bespoke UI. A notebook is the layer between: a YAML file that *declares what slices of the graph to show and what actions to allow*, not code. Each cell is a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) fed by a structured query, or a control (`Select`/`Toggle`/`Button`) that filters state or mutates the graph. See `examples/company.notebook.yaml`: a status filter, a decisions table, a `Signal → Decision → Actor` path, an ego subgraph, and a clause list with inline Approve/Reject buttons — no UI code anywhere. +Normally you inspect a graph by writing queries and reading JSON, or by building a bespoke UI. A notebook is the layer between: a YAML file that *declares what slices of the graph to show and what actions to allow*, not code. Each cell is a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) fed by a structured query, or a control (`Select`/`Toggle`/`Button`) that filters state or mutates the graph. See `examples/company-server.notebook.yaml`: a status filter, a decisions table, a `Signal → Decision → Actor` path, an ego subgraph, and a clause list with inline Approve/Reject buttons — no UI code anywhere. Two bets make it work: - **Typed lenses, not a generic graph viewer** — you name the view you want; the system renders it. -- **Write once, render anywhere** — the same YAML drives the Ink terminal UI and the React web UI, against an in-memory fixture (dev) or a live omnigraph-server (prod). It's bidirectional: lenses read the graph, controls and actions write back to it. +- **Write once, render anywhere** — the same YAML drives the Ink terminal UI and the React web UI, against a live omnigraph-server (a local cluster in dev, a remote server in prod). It's bidirectional: lenses read the graph, controls and actions write back to it. ## Commands @@ -27,8 +27,7 @@ pnpm --filter @modernrelay/notebook- build # rebuild one packag pnpm --filter @modernrelay/notebook- test # vitest run for one package pnpm --filter @modernrelay/notebook- test -- # single test file/name -pnpm tui examples/company.notebook.yaml # Ink TUI, fixture mode -pnpm tui examples/company-server.notebook.yaml # TUI, server mode — server URL + graph id +pnpm tui examples/company-server.notebook.yaml # Ink TUI, server mode — server URL + graph id # come from the notebook (run server-demo.sh first) pnpm --filter @modernrelay/notebook-web dev # Vite dev server at 127.0.0.1:5173 # add ?mode=server&server=/og (same-origin proxy) @@ -42,15 +41,14 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run ## Architecture -**One catalog, two renderers, one fixture-driven dev loop.** A notebook is YAML; each cell renders as a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) or a control (`Button`/`Toggle`/`Select`). Both the Ink TUI and the React Web app share the same catalog of component definitions and the same runtime; only the leaf component implementations and the host shell differ. +**One catalog, two renderers, one server-backed runtime.** A notebook is YAML; each cell renders as a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) or a control (`Button`/`Toggle`/`Select`). Both the Ink TUI and the React Web app share the same catalog of component definitions and the same runtime; only the leaf component implementations and the host shell differ. ### Package map | Package | Role | |---|---| -| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, fixture-query DSL, mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | -| `@modernrelay/notebook-fixture` | In-memory `FixtureSource` over JSON graphs; `/node` subpath holds the Node-only fs loader so it stays out of the browser bundle. | -| `@modernrelay/notebook-client` | `ServerSource` + `translateFixtureQuery` / `translateMutation` (fixture DSL → `.gq`) + a `Client` facade over the `@modernrelay/omnigraph` SDK (`/query` + `/mutate`, graph-scoped). | +| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, structured query DSL, mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | +| `@modernrelay/notebook-client` | **The only data source.** `ServerSource` + `translate` (structured DSL → `.gq`) + a `Client` facade over the `@modernrelay/omnigraph` SDK (`/query` + `/mutate`, graph-scoped). | | `@modernrelay/notebook-tui` | Ink renderer + CLI entry (`bin/omnigraph-tui.js`); host shell for terminal. | | `@modernrelay/notebook-web` | Vite + React + Tailwind renderer; host shell for browser. | | `@modernrelay/notebook` (`packages/cli`) | The published front-door CLI. Bundles every `@modernrelay/notebook-*` lib (tsup, `noExternal`) and ships the built web SPA in `web-dist/`. Subcommands: `view` (browser — static server + `/og` BFF proxy with server-side token injection, reusing `web/src/config.ts`'s URL-param contract), `tui` (calls `@modernrelay/notebook-tui` `main`), `validate`/`render`/`catalog`/`schema` (agent-DX, JSON out; schema via Zod 4 `z.toJSONSchema`). The workspace root is the private `notebook-workspace`; `@modernrelay/notebook` is the CLI, not the root. | @@ -62,22 +60,21 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run │ │ ▼ ▼ Source.capabilities/read/mutate assembleLensSpec() - (Fixture | Server) → json-render Spec + (ServerSource) → json-render Spec ``` -1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the `FixtureQuery` DSL (`nodes` / `path` / `ego`) and the `MutationSpec` discriminated union (currently only `set_field`). +1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the structured query DSL (`nodes` / `path` / `ego`) and the `MutationSpec` discriminated union (currently only `set_field`). 2. `@modernrelay/notebook-core`'s `createNotebookRuntime` validates notebook compatibility against `Source.capabilities()`, resolves `{ $state: "/ptr" }` expressions for data reads, invalidates only cells whose query dependencies changed, calls `Source.read()`, and hands results to `assembleLensSpec` (core's `catalog` module). Control cells skip reads and pass props through to `assembleControlSpec`. Per-cell errors are captured on `CellExecution.error`; runtime-level compatibility failures surface on `RuntimeSnapshot.error`. 3. core's `catalog` module exports `lensComponents` (Zod prop schemas + descriptions) and `lensActions` (`setState`, `mutate`). Author-time props are validated here; the renderer's `defineCatalog` consumes the same schemas. 4. The renderer (`packages/tui` or `packages/web`) calls `defineRegistry` against its UI library, supplying concrete Ink or React+Tailwind component implementations under the same component IDs (`Table`, `Path`, ...). The App subscribes to the runtime snapshot and passes each cell's `LensSpec` to ``. -### The `Source` interface and its two implementations +### The `Source` interface and its implementation -Defined in `@modernrelay/notebook-core` (its `runtime` module) as a capability-aware contract: `capabilities()`, `read(request, context)`, and `mutate(command, context)`. The notebook YAML is identical between modes where source capabilities overlap; unsupported features fail during runtime compatibility validation or with explicit source errors: +Defined in `@modernrelay/notebook-core` (its `runtime` module) as a capability-aware contract: `capabilities()`, `read(request, context)`, and `mutate(command, context)`. There is one implementation; unsupported features fail during runtime compatibility validation or with explicit source errors: -- **`FixtureSource`** (`@modernrelay/notebook-fixture`): runs the fixture-DSL query against an in-memory JSON graph; mutations update nodes in place per-process (no disk writeback). -- **`ServerSource`** (`@modernrelay/notebook-client`): translates fixture-DSL queries to `.gq` source via `translateFixtureQuery`/`translateMutation` and calls the SDK's `query`/`mutate` (omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`). `ego` queries are decomposed into center/incident reads and merged client-side. Cells may still bypass translation by setting deprecated `query.source` raw `.gq`. +- **`ServerSource`** (`@modernrelay/notebook-client`): the only source. Translates the structured DSL (`nodes`/`path`/`ego`) to `.gq` via `translateFixtureQuery`/`translateMutation` and calls the SDK's `query`/`mutate` (omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`). `ego` queries are decomposed into center/incident reads and merged client-side. Cells may bypass translation with deprecated `query.source` raw `.gq`. *(Target: cells reference predefined catalog queries by `ref` — see `dash-books-canon.md`.)* -Mode selection: `tui/src/index.tsx` and `web/src/App.tsx` pick a source from `notebook.fixture` (relative JSON path) vs `notebook.server` (URL), with CLI flags or URL flags (`?mode=server|fixture`, `?server=...`, `?notebook=...`) as overrides. +Connection: `tui/src/index.tsx`, `web/src/config.ts`, and `cli/src/source.ts` build a `ServerSource` from `notebook.server` + `graph` (cluster id), with CLI flags or URL flags (`?server=...`, `?graph=...`, `?notebook=...`) as overrides. ### State + mutations @@ -95,7 +92,7 @@ A data cell may additionally declare inline `controls: [...]` — control descri ### TypeScript config -Strict mode + `noUncheckedIndexedAccess`. All packages extend `tsconfig.base.json` and emit `dist/` with declaration files; consumers import from `@modernrelay/notebook-` (resolves to `dist/index.js`). The `@modernrelay/notebook-fixture/node` subpath splits Node-only fs loaders out of the browser bundle. +Strict mode + `noUncheckedIndexedAccess`. All packages extend `tsconfig.base.json` and emit `dist/` with declaration files; consumers import from `@modernrelay/notebook-` (resolves to `dist/index.js`). ## Server-mode prerequisites diff --git a/CLAUDE.md b/CLAUDE.md index 6c568d1..c3ca041 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,12 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Turn an OmniGraph graph database into a **read-and-act dashboard you describe in one YAML file** — rendered identically in a terminal and a browser. -Normally you inspect a graph by writing queries and reading JSON, or by building a bespoke UI. A notebook is the layer between: a YAML file that *declares what slices of the graph to show and what actions to allow*, not code. Each cell is a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) fed by a structured query, or a control (`Select`/`Toggle`/`Button`) that filters state or mutates the graph. See `examples/company.notebook.yaml`: a status filter, a decisions table, a `Signal → Decision → Actor` path, an ego subgraph, and a clause list with inline Approve/Reject buttons — no UI code anywhere. +Normally you inspect a graph by writing queries and reading JSON, or by building a bespoke UI. A notebook is the layer between: a YAML file that *declares what slices of the graph to show and what actions to allow*, not code. Each cell is a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) fed by a structured query, or a control (`Select`/`Toggle`/`Button`) that filters state or mutates the graph. See `examples/company-server.notebook.yaml`: a status filter, a decisions table, a `Signal → Decision → Actor` path, an ego subgraph, and a clause list with inline Approve/Reject buttons — no UI code anywhere. Two bets make it work: - **Typed lenses, not a generic graph viewer** — you name the view you want; the system renders it. -- **Write once, render anywhere** — the same YAML drives the Ink terminal UI and the React web UI, against an in-memory fixture (dev) or a live omnigraph-server (prod). It's bidirectional: lenses read the graph, controls and actions write back to it. +- **Write once, render anywhere** — the same YAML drives the Ink terminal UI and the React web UI, against a live omnigraph-server (a local cluster in dev, a remote server in prod). It's bidirectional: lenses read the graph, controls and actions write back to it. ## Commands @@ -27,8 +27,7 @@ pnpm --filter @modernrelay/notebook- build # rebuild one packag pnpm --filter @modernrelay/notebook- test # vitest run for one package pnpm --filter @modernrelay/notebook- test -- # single test file/name -pnpm tui examples/company.notebook.yaml # Ink TUI, fixture mode -pnpm tui examples/company-server.notebook.yaml # TUI, server mode — server URL + graph id +pnpm tui examples/company-server.notebook.yaml # Ink TUI, server mode — server URL + graph id # come from the notebook (run server-demo.sh first) pnpm --filter @modernrelay/notebook-web dev # Vite dev server at 127.0.0.1:5173 # add ?mode=server&server=/og (same-origin proxy) @@ -42,15 +41,14 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run ## Architecture -**One catalog, two renderers, one fixture-driven dev loop.** A notebook is YAML; each cell renders as a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) or a control (`Button`/`Toggle`/`Select`). Both the Ink TUI and the React Web app share the same catalog of component definitions and the same runtime; only the leaf component implementations and the host shell differ. +**One catalog, two renderers, one server-backed runtime.** A notebook is YAML; each cell renders as a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) or a control (`Button`/`Toggle`/`Select`). Both the Ink TUI and the React Web app share the same catalog of component definitions and the same runtime; only the leaf component implementations and the host shell differ. ### Package map | Package | Role | |---|---| -| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, fixture-query DSL, mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | -| `@modernrelay/notebook-fixture` | In-memory `FixtureSource` over JSON graphs; `/node` subpath holds the Node-only fs loader so it stays out of the browser bundle. | -| `@modernrelay/notebook-client` | `ServerSource` + `translateFixtureQuery` / `translateMutation` (fixture DSL → `.gq`) + a `Client` facade over the `@modernrelay/omnigraph` SDK (`/query` + `/mutate`, graph-scoped). | +| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, structured query DSL, mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | +| `@modernrelay/notebook-client` | **The only data source.** `ServerSource` + `translate` (structured DSL → `.gq`) + a `Client` facade over the `@modernrelay/omnigraph` SDK (`/query` + `/mutate`, graph-scoped). | | `@modernrelay/notebook-tui` | Ink renderer + CLI entry (`bin/omnigraph-tui.js`); host shell for terminal. | | `@modernrelay/notebook-web` | Vite + React + Tailwind renderer; host shell for browser. | | `@modernrelay/notebook` (`packages/cli`) | The published front-door CLI. Bundles every `@modernrelay/notebook-*` lib (tsup, `noExternal`) and ships the built web SPA in `web-dist/`. Subcommands: `view` (browser — static server + `/og` BFF proxy with server-side token injection, reusing `web/src/config.ts`'s URL-param contract), `tui` (calls `@modernrelay/notebook-tui` `main`), `validate`/`render`/`catalog`/`schema` (agent-DX, JSON out; schema via Zod 4 `z.toJSONSchema`). The workspace root is the private `notebook-workspace`; `@modernrelay/notebook` is the CLI, not the root. | @@ -62,22 +60,21 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run │ │ ▼ ▼ Source.capabilities/read/mutate assembleLensSpec() - (Fixture | Server) → json-render Spec + (ServerSource) → json-render Spec ``` -1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the `FixtureQuery` DSL (`nodes` / `path` / `ego`) and the `MutationSpec` discriminated union (currently only `set_field`). +1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the structured query DSL (`nodes` / `path` / `ego`) and the `MutationSpec` discriminated union (currently only `set_field`). 2. `@modernrelay/notebook-core`'s `createNotebookRuntime` validates notebook compatibility against `Source.capabilities()`, resolves `{ $state: "/ptr" }` expressions for data reads, invalidates only cells whose query dependencies changed, calls `Source.read()`, and hands results to `assembleLensSpec` (core's `catalog` module). Control cells skip reads and pass props through to `assembleControlSpec`. Per-cell errors are captured on `CellExecution.error`; runtime-level compatibility failures surface on `RuntimeSnapshot.error`. 3. core's `catalog` module exports `lensComponents` (Zod prop schemas + descriptions) and `lensActions` (`setState`, `mutate`). Author-time props are validated here; the renderer's `defineCatalog` consumes the same schemas. 4. The renderer (`packages/tui` or `packages/web`) calls `defineRegistry` against its UI library, supplying concrete Ink or React+Tailwind component implementations under the same component IDs (`Table`, `Path`, ...). The App subscribes to the runtime snapshot and passes each cell's `LensSpec` to ``. -### The `Source` interface and its two implementations +### The `Source` interface and its implementation -Defined in `@modernrelay/notebook-core` (its `runtime` module) as a capability-aware contract: `capabilities()`, `read(request, context)`, and `mutate(command, context)`. The notebook YAML is identical between modes where source capabilities overlap; unsupported features fail during runtime compatibility validation or with explicit source errors: +Defined in `@modernrelay/notebook-core` (its `runtime` module) as a capability-aware contract: `capabilities()`, `read(request, context)`, and `mutate(command, context)`. There is one implementation; unsupported features fail during runtime compatibility validation or with explicit source errors: -- **`FixtureSource`** (`@modernrelay/notebook-fixture`): runs the fixture-DSL query against an in-memory JSON graph; mutations update nodes in place per-process (no disk writeback). -- **`ServerSource`** (`@modernrelay/notebook-client`): translates fixture-DSL queries to `.gq` source via `translateFixtureQuery`/`translateMutation` and calls the SDK's `query`/`mutate` (omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`). `ego` queries are decomposed into center/incident reads and merged client-side. Cells may still bypass translation by setting deprecated `query.source` raw `.gq`. +- **`ServerSource`** (`@modernrelay/notebook-client`): the only source. Translates the structured DSL (`nodes`/`path`/`ego`) to `.gq` via `translateFixtureQuery`/`translateMutation` and calls the SDK's `query`/`mutate` (omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`). `ego` queries are decomposed into center/incident reads and merged client-side. Cells may bypass translation with deprecated `query.source` raw `.gq`. *(Target: cells reference predefined catalog queries by `ref` — see `dash-books-canon.md`.)* -Mode selection: `tui/src/index.tsx` and `web/src/App.tsx` pick a source from `notebook.fixture` (relative JSON path) vs `notebook.server` (URL), with CLI flags or URL flags (`?mode=server|fixture`, `?server=...`, `?notebook=...`) as overrides. +Connection: `tui/src/index.tsx`, `web/src/config.ts`, and `cli/src/source.ts` build a `ServerSource` from `notebook.server` + `graph` (cluster id), with CLI flags or URL flags (`?server=...`, `?graph=...`, `?notebook=...`) as overrides. ### State + mutations @@ -95,7 +92,7 @@ A data cell may additionally declare inline `controls: [...]` — control descri ### TypeScript config -Strict mode + `noUncheckedIndexedAccess`. All packages extend `tsconfig.base.json` and emit `dist/` with declaration files; consumers import from `@modernrelay/notebook-` (resolves to `dist/index.js`). The `@modernrelay/notebook-fixture/node` subpath splits Node-only fs loaders out of the browser bundle. +Strict mode + `noUncheckedIndexedAccess`. All packages extend `tsconfig.base.json` and emit `dist/` with declaration files; consumers import from `@modernrelay/notebook-` (resolves to `dist/index.js`). ## Server-mode prerequisites diff --git a/README.md b/README.md index 099fc1d..a9c742e 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,18 @@ Notebook UI for [OmniGraph](https://github.com/ModernRelay/omnigraph). Each notebook cell is a typed *lens primitive* (Table, Path, Subgraph) rendered from a structured query — not a generic graph viewer. -One catalog of components, two renderers (terminal and web), one fixture-driven dev loop. +One catalog of components, two renderers (terminal and web), one server-backed runtime. ## What it's for Turn an OmniGraph graph database into a **read-and-act dashboard you describe in one YAML file** — rendered identically in a terminal and a browser. -Normally you inspect a graph by writing queries and reading JSON, or by building a bespoke UI. A notebook is the layer between: a YAML file that *declares what slices of the graph to show and what actions to allow*, not code. Each cell is a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) fed by a structured query, or a control (`Select`/`Toggle`/`Button`) that filters state or mutates the graph. See `examples/company.notebook.yaml`: a status filter, a decisions table, a `Signal → Decision → Actor` path, an ego subgraph, and a clause list with inline Approve/Reject buttons — no UI code anywhere. +Normally you inspect a graph by writing queries and reading JSON, or by building a bespoke UI. A notebook is the layer between: a YAML file that *declares what slices of the graph to show and what actions to allow*, not code. Each cell is a typed lens (`Table`/`Path`/`Subgraph`/`ActionList`) fed by a structured query, or a control (`Select`/`Toggle`/`Button`) that filters state or mutates the graph. See `examples/company-server.notebook.yaml`: a status filter, a decisions table, a `Signal → Decision → Actor` path, an ego subgraph, and a clause list with inline Approve/Reject buttons — no UI code anywhere. Two bets make it work: - **Typed lenses, not a generic graph viewer** — you name the view you want; the system renders it. -- **Write once, render anywhere** — the same YAML drives the Ink terminal UI and the React web UI, against an in-memory fixture (dev) or a live omnigraph-server (prod). It's bidirectional: lenses read the graph, controls and actions write back to it. +- **Write once, render anywhere** — the same YAML drives the Ink terminal UI and the React web UI, against a live omnigraph-server (a local cluster in dev, a remote server in prod). It's bidirectional: lenses read the graph, controls and actions write back to it. ## Install & run (CLI) @@ -57,20 +57,20 @@ npx @modernrelay/notebook render nb.yaml # headless run → cell resul ```bash pnpm install pnpm -r build -pnpm tui examples/company.notebook.yaml # terminal +scripts/server-demo.sh # boot a local omnigraph cluster (graph `company`) +pnpm tui examples/company-server.notebook.yaml # terminal (needs the cluster above) pnpm --filter @modernrelay/notebook-web dev # browser at 127.0.0.1:5173 pnpm --filter @modernrelay/notebook build # bundle the CLI (tsup) + web-dist ``` -The fixture demos render the same cells against the in-memory `examples/fixtures/company-context.json`. +`scripts/server-demo.sh` stands up a local filesystem-backed omnigraph cluster; the TUI and web app render the demo cells against it (the web app talks to it same-origin via the Vite `/og` proxy). ## Packages | Package | Purpose | |---|---| | `@modernrelay/notebook-core` | The engine — start here. Three modules behind one entry: `spec` (Zod YAML schemas + query DSL), `catalog` (`lensComponents`/`lensActions` + `assembleLensSpec`), `runtime` (capability-aware execution, state, mutations). The `@json-render/core` analog. | -| `@modernrelay/notebook-fixture` | In-memory loader + nodes/path/ego query runner. | -| `@modernrelay/notebook-client` | HTTP client + live `ServerSource` adapter for omnigraph-server. | +| `@modernrelay/notebook-client` | The only data source — `ServerSource` + a `Client` facade over the `@modernrelay/omnigraph` SDK. | | `@modernrelay/notebook-tui` | Ink renderer + the `omnigraph-tui` binary. | | `@modernrelay/notebook-web` | Vite + React + Tailwind v4 SPA. | | `@modernrelay/notebook` (`packages/cli`) | The published CLI — bundles the libs + ships the web SPA; `view`/`tui`/`validate`/`render`/`catalog`/`schema`. | diff --git a/dash-books-canon.md b/dash-books-canon.md new file mode 100644 index 0000000..6fc703b --- /dev/null +++ b/dash-books-canon.md @@ -0,0 +1,284 @@ +# Dash-books — Canon + +> The single source of truth for what this project is, how it's built, where it stands, and +> where it's going. Supersedes scattered notes; when this doc and code disagree, fix one of them. + +--- + +## 1. Overall project concept + +**Turn an OmniGraph graph into a read-and-act dashboard you describe in one YAML file — rendered from +one typed result contract in a browser and a terminal.** + +A "dash-book" is a notebook: a YAML document whose cells are typed **lenses** (`Table`, `Path`, +`Subgraph`, `ActionList`) and **controls** (`Button`, `Toggle`, `Select`). You declare *what slice of +the graph to show and what actions to allow*; you never write UI code. The same YAML drives a +React/Tailwind web renderer and an Ink terminal renderer from one shared result contract. The browser +is the first-class rich renderer; the terminal is a useful degradation over the same data and action +model, not the ceiling on what the browser may express. It is bidirectional: lenses **read** the +graph, controls and actions **write** to it. + +### The guiding principle: consistency through server-owned native queries + +A dash-book does **not invent queries**. Every cell binds to a **predefined, server-owned query** and +invokes it through the **TypeScript SDK** (`@modernrelay/omnigraph`). That query is native `.gq` with +typed params and a declared result contract. One query definition is shared across the `omnigraph` +CLI, the SDK, and the dash-book — so a dashboard **cannot drift** from canon: a field rename, query +fix, param change, or result-shape change happens once, in the server-owned catalog, and every surface +updates together. The dash-book is a *presentation + interaction layer over canonical queries and +actions*, nothing more. + +Two consequences fall out of that principle, and they define the rest of this doc: + +1. **No local mock data.** There is no in-memory fixture graph. A dash-book runs against a real + omnigraph-server (or a local cluster), always through the SDK. *(Fixture mode is deleted — §3.)* +2. **No client-side query generation (target).** The client stops compiling a query DSL into `.gq`; + it invokes predefined native `.gq` queries by name. Raw inline `.gq` exists only as an explicit, + capability-gated escape hatch. *(In progress — §4.)* + +--- + +## 2. Key modules + +| Package | Role | +|---|---| +| `@modernrelay/notebook-core` | The engine, one package / three modules: **`spec`** (Zod YAML schemas + query model + `parseNotebook`), **`catalog`** (`lensComponents`/`lensActions` + `assembleLensSpec`/`assembleControlSpec`), **`runtime`** (`createNotebookRuntime`: execution, `$state` resolution, dependency invalidation, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog — start here. | +| `@modernrelay/notebook-client` | **The only data source.** `ServerSource` (implements the runtime `Source` contract) + `Client`, a thin facade over the `@modernrelay/omnigraph` SDK. All graph I/O goes through the SDK — there is no direct HTTP to the graph. | +| `@modernrelay/notebook-tui` | Ink terminal renderer + `omnigraph-tui` bin. | +| `@modernrelay/notebook-web` | Vite + React + Tailwind browser renderer (+ ⌘K palette). | +| `@modernrelay/notebook` (`packages/cli`) | The published front door. Bundles the libs + ships the web SPA. Subcommands: `view` (browser), `tui` (terminal), `validate`/`render`/`catalog`/`schema` (agent-DX, JSON out). | + +**Data flow:** `YAML → parseNotebook → createNotebookRuntime → RuntimeSnapshot → Renderer → UI`, with +`ServerSource.read/mutate` (via the SDK) supplying data and `assembleLensSpec` producing the +json-render spec each renderer draws. + +**Removed:** `@modernrelay/notebook-fixture` (the in-memory JSON graph source) — see §3. + +--- + +## 3. Current state + +- **One renderer engine, two host shells, one source.** core (spec/catalog/runtime) feeds tui and web; + the only `Source` is `ServerSource` over the SDK. +- **Fixture mode deleted.** No `@modernrelay/notebook-fixture` package, no in-memory mock graph, no + top-level `notebook.fixture` selection, no example fixtures. A dash-book runs **only against + omnigraph-server** (a live server or a local cluster from `scripts/server-demo.sh`). +- **SDK-only transport.** `ServerSource → Client → @modernrelay/omnigraph`. The only raw HTTP in the + repo is the CLI's local static web server + `/og` reverse proxy for `view` — not graph access. +- **0.7 cluster-only.** omnigraph-server 0.7.0+ serves every graph under `/graphs/{graph}/…`, so server + mode requires a graph id. Connection today is ad-hoc: `--server ` / `--graph` / `--token` + (+ a few env vars). +- **Interim wart — client still generates queries.** Cells still carry the structured query DSL + (`query.fixture` = `nodes`/`path`/`ego`), which `ServerSource` compiles to ad-hoc `.gq` via + `translate.ts` (incl. ego decomposition + identifier-sanitizing regexes) and ships as + `og.query({ query })`. This is the thing §4 removes. The field is still named `fixture` for + historical reasons; that rename rides along with the §4 work. + +--- + +## 4. Target shape + +The end state realizes the §1 principle through eight architectural choices. + +### 4.1 Cells reference native `.gq` catalog queries by name +```yaml +- id: decisions-by-urgency + lens: Table + query: { ref: decisions_by_urgency, params: { status: { $state: "/filters/status" } } } +``` + +- `query.ref` names a server-owned catalog query authored in native `.gq`. +- `ServerSource.read` becomes **`og.queries.invoke(ref, { params, branch, snapshot })`** — the SDK's + dedicated stored-query path (`POST /queries/{name}`); the source comes from the registry, never the + cell. This is a *different SDK method* from `og.query({ query })` (the ad-hoc path = §4.2). +- **Param types come from config, available today:** `og.queries.list()` returns each query's typed + `ParamDescriptor`s (`ParamKind` = string/int/float/bool/date/datetime/bigint/blob/vector/list, plus + nullability). `notebook validate` checks the `ref` resolves and the cell's params match. +- **Delete the client-side query compiler:** `translate.ts`, the ego decomposition planner, and the + identifier-sanitization guards all go away. `ServerSource` collapses to a thin SDK caller. +- Retire the `nodes`/`path`/`ego` DSL. The full `.gq` language handles filters, ordering, multi-hop + traversals, aggregation, and future query features directly. +- `$state` params keep resolving client-side and pass through as typed `params`. + +`query.ref` is a contract change, not a field rename — and **not** a new naming problem: the SDK +already separates the paths cleanly (`og.queries.invoke(name)` for registry queries vs +`og.query({ query, name })` for ad-hoc, where the legacy `query.name` only ever selects within an +inline payload). The migration is a vertical slice: Zod accepts `query.ref`; `ReadRequest` carries +`queryRef`; `ServerSource.read` routes `ref` → `og.queries.invoke` and `rawGq` → `og.query`; +capabilities advertise named-query support. + +Every query a dash-book runs by default is a **named catalog query**: authored once, lint-validated at +`cluster apply`, Cedar-gated (`invoke_query`), and **identical** to what `omnigraph query ` and +any other SDK consumer run. The dashboard is provably a view over canon. + +### 4.2 Raw `.gq` is an escape hatch, not the default +Inline query text is useful for local prototyping, debugging, and privileged one-off dashboards, but +it must not become the normal notebook contract: + +```yaml +- id: scratch + lens: Table + query: + rawGq: | + query scratch($status: String) { + match { $d: Decision { status: $status } } + return { $d.slug as slug, $d.title as title } + } + params: { status: proposed } +``` + +- `rawGq` is capability-gated and off by default in production/operator contexts. +- Validation warns that raw queries are not canonical catalog queries. +- Raw reads still use native `.gq` and typed params; they do not revive the fixture DSL. + +### 4.3 Lenses are dumb views over typed result envelopes +Lenses render `{ result, schema, ctx }` and know nothing about how the query was authored or invoked, +so adding a query feature needs no new lens and adding a visualization needs no query change. Target +envelope shapes: `rows` (tabular), `graph` (nodes/edges), `tree` (nested/traversal-shaped). + +**Where the types come from (the 0.7 paradigm) — config, not invention:** +- **Params** — the query catalog (`og.queries.list()` → typed `ParamDescriptor`s). Available now. +- **Field types / enums / nullability** — the `.pg` schema (`og.schema.get()` → `.pg` source). + `company.pg` already declares `enum(proposed, accepted, …)`, `String?`, `Date`, etc. (No units and + no display labels in `.pg` today — labels derive from field names; units are absent.) + +> **Decision — output types come from config (option a); the type data is never missing.** All type +> information already lives in config: the `.pg` schema (field types, enums, nullability) and the query +> catalog (queries + their params). A notebook is always built with full type knowledge — **we never +> render blind.** The raw HTTP read response happens to be untyped (`ReadOutput` = +> `{ columns: string[], rows: unknown }`), but that is irrelevant: the renderer reads **types from +> config, values from the response.** +> +> So this is not a "missing data" problem — it's a single-source-of-truth choice about *who resolves +> "query → output columns + their types" and where it's published.* The answer is the **server**, which +> already resolves every `return` expression's type when it compiles the query. omnigraph publishes +> those resolved output types in the catalog (`GET /queries`, alongside the params it already exposes), +> so nothing re-derives them. This is a cross-repo dependency on omnigraph-server/SDK; until it ships, +> v1 uses author-declared columns (today's Table-lens model — still config-grounded). Rejected: the +> notebook re-resolving outputs from config itself (duplicates work the server already does), and +> notebook-owned types as the permanent model (breaks single-source). + +### 4.4 Two view tiers, with web first-class and TUI degraded +Both tiers draw from a **curated, closed catalog of well-tested components** — the existing +`lensComponents` model, extended deliberately. Authors **compose** from the catalog; they never author +components, and there is no universal UI generator (see §4.9). + +- **Auto tier:** given a result envelope plus schema metadata, infer the component. Examples: + enum → badge/filter, number+unit → formatted metric, date → relative/absolute date, refs → links, + edges → graph/path view. This should cover most operator dashboards with little or no lens config. +- **Component tier:** a fixed vocabulary of blessed layout + display components for authored views — + grid, rows, tabs, panels, charts, forms, inputs, markdown, conditional visibility. Each is a vetted, + tested catalog entry, not a user-authored or runtime-loaded component. + +Explicit `lens:` always wins; the auto tier fills in only when a cell omits `lens`. The auto tier +depends on config-declared query outputs (§4.3) — pending that omnigraph feature, v1 leans on explicit +lenses with author-declared columns. + +The browser renderer is first-class for the component tier. The TUI renders the same notebook and +actions through best-fit terminal views, especially the auto tier, but TUI parity must not cap the +browser vocabulary. + +**Bindings stay declarative, not a language.** Cells bind component props to `$state` pointers, query +params (today's model), and result columns + schema metadata. Display-shaping (format, unit, label) is +component-prop + metadata driven. There is **no** client-side expression/formula language and **no** +control flow — that is the line between a dashboard runtime and a UI framework. + +**Extensibility is by contribution, not plugins.** A new component lands as an in-tree, reviewed, +tested PR to the catalog — never as third-party, runtime-loaded, or sandboxed code. Each catalog +component ships a TUI renderer, or falls back to a table. + +### 4.5 Writes are server-owned, schema-validated actions — *(Phase 2; v1 is read-only)* +**v1 ships read-only representation** (operating decision): no write path beyond today's interim +`set_field`, and the model below is deferred. When it lands, it mirrors named queries: + +- Notebooks call named server-owned mutations/actions by `ref`, with typed params. +- The server validates create/update/delete operations for nodes and edges against the graph schema. +- Writes support multi-field updates and typed values (`string`, `number`, `bool`, `date`, enum, + ref) inside one branch transaction. +- Inputs (`text`, `number`, `date`, `select`, `ref-picker`, etc.) bind to params, so forms and data + entry are first-class. +- Writes land on a branch by default, with review/merge affordances matching the branch-not-main + operating model. + +No permanent client-generated `.gq` mutation path: write text belongs in the same server-owned, +authorized catalog as reads. + +### 4.6 Runtime dataflow is an explicit dependency DAG +The JSON-pointer state store stays as a simple substrate, but dependency tracking becomes explicit: +inputs, controls, query params, reads, mutations, and cells form a DAG. When an input changes, only +downstream queries (and the cells that render them) re-run. This preserves the current selective +invalidations while making dependencies author-visible and extensible beyond `$state` scans. (No +client-side computed/expression layer — see §4.4.) + +### 4.7 Connection aligned with omnigraph 0.7 (RFC-011) +Stop inventing env vars; become a well-behaved operator-config client (server scope): +- Read `~/.omnigraph/config.yaml` — resolve `--server ` via `servers:`, pick + `--profile`/`$OMNIGRAPH_PROFILE`/`defaults` (server + `default_graph`). +- Read `~/.omnigraph/credentials` (0600) with the token chain + `OMNIGRAPH_TOKEN_` → credentials `[server]` → `OMNIGRAPH_BEARER_TOKEN`. +- Drop the invented `OMNIGRAPH_TOKEN` / `OMNIGRAPH_GRAPH_ID`. +- Result: `notebook view dash.notebook.yaml` works with **zero flags** once you've `omnigraph login`'d — + same as `omnigraph query`. + +CLI, TUI, web, and the SDK facade should not each resolve tokens and graph ids independently. Put the +Node-side operator config + credentials chain in one shared resolver. Browser mode is necessarily +different: credentials should arrive through the `notebook view` same-origin proxy (server-side token +injection) or explicit dev URL config, not by reading operator files. + +### 4.8 Dev loop without fixtures +Authoring/iteration runs against a **local cluster** (`scripts/server-demo.sh`) instead of an in-memory +JSON graph. Desirable CLI affordances (independent of the above): `--watch` hot-reload, per-command +`--help`, `--version`, and an `omnigraph-notebook` bin alias so `omnigraph notebook ` dispatches +here if/when the Rust CLI adopts git-style plugin discovery (the renderer stays in Node). + +Fixture deletion should be enforced structurally. The fixture package and examples are gone, but the +notebook schema should reject stale top-level `fixture:` keys instead of silently stripping unknown +fields. Make the schema strict, update tests that still include old fixture fields, and add an explicit +rejection test for removed fixture-mode config. (Internal tool — no external authors to break — so a +strict, single-version schema is safe; no version negotiation or legacy-v1 support is needed.) + +### 4.9 Non-goals +- The dash-book is **not** hosted on the server — it stays a client-side artifact. (Queries are + server-owned; the notebook is not.) +- Rendering stays in **Node** (Ink/React); it is never reimplemented in Rust. +- The dash-book runtime is **not** an arbitrary end-user product UI framework. Concretely: + - **In:** composing a curated, tested component catalog; components bound to server-owned queries + and actions; declarative `$state` / param / result bindings; the auto tier. + - **Out:** user-authored or third-party runtime-loaded components; a client-side expression/formula + language or control flow; presentation logic not anchored to a catalog query or action. + - For bespoke product apps, the right direction is scaffolding/generating a normal web app from the + graph schema, typed query client, and hooks. The notebook remains an operator/dashboard runtime. + +--- + +## Migration ledger +- [x] Consolidate spec + catalog + runtime → `@modernrelay/notebook-core`. +- [x] **Delete fixture mode** (package, top-level selection, example fixtures, source-selection). +**Phase 1 — read-only canon (v1).** +- [ ] Cells reference catalog queries by `ref`; route `ServerSource.read` → `og.queries.invoke`; add + `ReadRequest.queryRef` plumbing; delete `translate.ts` + the `nodes`/`path`/`ego` DSL. +- [ ] `rawGq` escape hatch: capability-gated, off by default, validation warns. +- [ ] `notebook validate` against the catalog — `ref` resolves and params match `ParamDescriptor`s + (`og.queries.list()`). +- [ ] Make the schema strict; reject stale fixture-mode keys (internal tool — no version support). +- [ ] Operator-config connection client (shared Node resolver: config.yaml + credentials, named + servers, keyed tokens, profiles; browser uses the `view` proxy / dev config). +- [ ] Render explicit lenses over named queries; web-only components degrade to a **table** in the TUI. +- [ ] CLI DX: `--watch`, per-command help, `--version`, `omnigraph-notebook` bin alias. +- [ ] Refresh CLAUDE.md / README.md / AGENTS.md / server-demo to the post-fixture, predefined-query model. + +**Cross-repo dependency for Phase 2 — declared query outputs (§4.3, decided: option a).** omnigraph +extends the query catalog to declare output columns + types (`GET /queries`), mirroring params. Gates +the auto tier and output-binding validation; until it ships, v1 uses author-declared columns. + +**Phase 2 — representation depth.** +- [ ] Typed result envelopes (`rows` / `graph` / `tree`) with schema-derived metadata (pending the decision above). +- [ ] Auto-render tier from result metadata; explicit `lens:` overrides. +- [ ] Curated, tested web-first component/layout catalog; TUI best-fit/degraded over the same contract. +- [ ] Extend the catalog by in-tree, reviewed contribution (no third-party/sandboxed lenses); TUI + renderer or table fallback per component. +- [ ] Explicit dependency DAG (inputs, controls, query params, reads, cells) — no client expression layer. + +**Phase 3 — writes (deferred).** +- [ ] Named server-owned mutations/actions by `ref`, typed inputs/forms, schema validation, branch + transactions, review/merge. diff --git a/examples/company.notebook.yaml b/examples/company.notebook.yaml deleted file mode 100644 index 5a5c1fe..0000000 --- a/examples/company.notebook.yaml +++ /dev/null @@ -1,198 +0,0 @@ -version: 1 -title: Company context (mock) -fixture: ./fixtures/company-context.json - -# Per-element approval demo: pick a policy → see its clauses, each with -# inline Approve/Reject buttons. The buttons fire the registered approve/ -# reject actions with `{ id: row.id }`; outcomes accumulate at /approvals/ -# and the ActionList badges each row from that state. -# -# Other demos remain: a status filter on Decisions, a signal→decision→actor -# path, evidence trail, hub Decision neighborhood, actor portfolio. - -cells: - - # ── Filter (driver of decisions-by-urgency) ──────────────────────────── - - - id: filter-decision-status - lens: Select - props: - label: Filter by status - options: ["", proposed, accepted, rejected, superseded] - value: { $bindState: "/filters/decision_status" } - - # ── Policy review (driver of policy-clause-review) ───────────────────── - - - id: select-policy - lens: Select - props: - label: Review policy - options: - - policy-data-residency - - policy-oss - - policy-hiring-rubric - - policy-secrets - - policy-customer-data - value: { $bindState: "/selection/policy_id" } - - - id: policy-clause-review - lens: ActionList - # Ego from the selected Policy out via hasClause to its PolicyClauses. - # When /selection/policy_id is empty, the executor drops the `id` filter - # and the ego matches all Policies → all clauses appear. - query: - fixture: - kind: ego - center: - type: Policy - where: - id: { $state: "/selection/policy_id" } - out: [hasClause] - in: [] - project: - - { var: neighbor.id, as: id } - - { var: neighbor.title, as: title } - - { var: neighbor.text, as: body } - - { var: neighbor.status, as: status } # status is what the buttons mutate - - { var: center.id, as: policy_id } - props: - id_column: id - title_column: title - body_column: body - # Status badge reads from the row's `status` field (post-mutation truth). - # No more state-only approve/reject — each click is one atomic mutation - # against the source (FixtureSource here, omnigraph-server in prod). - status_field: status - meta_columns: [policy_id] - actions: - - label: Approve - variant: primary - mutation: - kind: set_field - target_type: PolicyClause - field: status - value: approved - - label: Reject - variant: danger - mutation: - kind: set_field - target_type: PolicyClause - field: status - value: rejected - - # ── Tables ───────────────────────────────────────────────────────────── - - - id: decisions-by-urgency - lens: Table - query: - fixture: - kind: nodes - where: - type: Decision - status: { $state: "/filters/decision_status" } - project: [id, title, status, urgency, decided_at] - order_by: { field: urgency, direction: asc } - props: - columns: - - { key: id, label: ID } - - { key: title, label: Title } - - { key: status, label: Status } - - { key: urgency, label: Urgency } - - { key: decided_at, label: Decided } - - - id: open-issues - lens: Table - query: - fixture: - kind: nodes - where: { type: Issue, status: open } - project: [id, title, severity, area] - order_by: { field: severity, direction: asc } - props: - columns: - - { key: id, label: ID } - - { key: title, label: Title } - - { key: severity, label: Severity } - - { key: area, label: Area } - - # ── Paths ────────────────────────────────────────────────────────────── - - - id: signal-to-owner - lens: Path - query: - fixture: - kind: path - steps: - - { var: s, type: Signal } - - { edge: triggers, var: d, type: Decision, direction: out } - - { edge: owns, var: a, type: Actor, direction: in } - project: - - { var: s.title, as: signal_title } - - { literal: triggers, as: triggers_label } - - { var: d.title, as: decision_title } - - { literal: owned by, as: owns_label } - - { var: a.name, as: actor_name } - props: - steps: - - { from_column: signal_title, predicate_column: triggers_label, to_column: decision_title } - - { from_column: decision_title, predicate_column: owns_label, to_column: actor_name } - - - id: evidence-trail - lens: Path - query: - fixture: - kind: path - steps: - - { var: r, type: Reference } - - { edge: substantiates, var: d, type: Decision } - - { edge: affects, var: i, type: Issue } - project: - - { var: r.title, as: reference } - - { literal: substantiates, as: p1 } - - { var: d.title, as: decision } - - { literal: affects, as: p2 } - - { var: i.title, as: issue } - props: - steps: - - { from_column: reference, predicate_column: p1, to_column: decision } - - { from_column: decision, predicate_column: p2, to_column: issue } - - # ── Subgraphs (ego) ──────────────────────────────────────────────────── - - - id: decision-neighbors - lens: Subgraph - query: - fixture: - kind: ego - center: - type: Decision - where: { id: eu-data-residency } - out: [governed_by, affects, resolves, discussed_in, supersedes] - in: [owns, triggers, substantiates, raises, participated] - project: - - { var: center.id, as: id } - - { var: center.title, as: name } - - { var: edge_type, as: predicate } - - { var: neighbor.id, as: neighbor } - props: - center: { type: Decision, id_column: id, label_column: name } - depth: 1 - - - id: actor-portfolio - lens: Subgraph - query: - fixture: - kind: ego - center: - type: Actor - where: { id: priya } - out: [owns, owns_policy, authored, participated] - in: [] - project: - - { var: center.id, as: id } - - { var: center.name, as: name } - - { var: edge_type, as: predicate } - - { var: neighbor.title, as: neighbor } - props: - center: { type: Actor, id_column: id, label_column: name } - depth: 1 diff --git a/examples/fixtures/company-context.json b/examples/fixtures/company-context.json deleted file mode 100644 index b814250..0000000 --- a/examples/fixtures/company-context.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "version": 1, - "title": "Company context (mock)", - "nodes": [ - { "type": "Actor", "id": "andrew", "name": "Andrew Chen", "role": "CEO", "team": "exec" }, - { "type": "Actor", "id": "priya", "name": "Priya Ramaswamy", "role": "CTO", "team": "exec" }, - { "type": "Actor", "id": "miguel", "name": "Miguel Alvarez", "role": "VP Engineering", "team": "engineering" }, - { "type": "Actor", "id": "sarah", "name": "Sarah O'Connor", "role": "VP Product", "team": "product" }, - { "type": "Actor", "id": "james", "name": "James Whitfield", "role": "VP Sales", "team": "gtm" }, - { "type": "Actor", "id": "elena", "name": "Elena Vasquez", "role": "Head of Security", "team": "security" }, - { "type": "Actor", "id": "ruben", "name": "Ruben Holmberg", "role": "Senior Engineer", "team": "engineering" }, - { "type": "Actor", "id": "taro", "name": "Taro Sasaki", "role": "Staff Engineer", "team": "engineering" }, - - { "type": "Decision", "id": "adopt-soc2", "title": "Adopt SOC2 Type II controls", "status": "accepted", "urgency": "high", "decided_at": "2026-03-15" }, - { "type": "Decision", "id": "open-source-engine", "title": "Open-source the core engine", "status": "accepted", "urgency": "high", "decided_at": "2026-02-09" }, - { "type": "Decision", "id": "hire-vp-marketing", "title": "Hire VP of Marketing", "status": "proposed", "urgency": "high", "decided_at": null }, - { "type": "Decision", "id": "private-ai-offering", "title": "Launch private AI offering", "status": "proposed", "urgency": "high", "decided_at": "2026-04-22" }, - { "type": "Decision", "id": "eu-data-residency", "title": "Provide EU data residency", "status": "accepted", "urgency": "high", "decided_at": "2026-04-02" }, - { "type": "Decision", "id": "zero-retention-mode", "title": "Add zero-retention mode for enterprise", "status": "accepted", "urgency": "medium", "decided_at": "2026-03-28" }, - { "type": "Decision", "id": "lance-4-migration", "title": "Migrate to Lance 4.x", "status": "superseded", "urgency": "medium", "decided_at": "2026-01-12" }, - { "type": "Decision", "id": "lance-5-roadmap", "title": "Plan Lance 5.x adoption", "status": "proposed", "urgency": "low", "decided_at": null }, - { "type": "Decision", "id": "q3-pricing-revamp", "title": "Revamp Q3 pricing tiers", "status": "proposed", "urgency": "high", "decided_at": null }, - - { "type": "Issue", "id": "fts-recall-degradation", "title": "FTS recall drops after compaction", "severity": "high", "status": "open", "area": "storage" }, - { "type": "Issue", "id": "cold-start-latency", "title": "Cold-start latency exceeds 5s", "severity": "medium", "status": "open", "area": "infrastructure" }, - { "type": "Issue", "id": "branch-merge-conflicts", "title": "Branch merge produces stale indexes", "severity": "high", "status": "open", "area": "storage" }, - { "type": "Issue", "id": "enterprise-deal-stalled", "title": "Enterprise deal stalled on residency", "severity": "high", "status": "mitigated", "area": "gtm" }, - { "type": "Issue", "id": "hiring-pipeline-thin", "title": "VP Marketing pipeline thin", "severity": "medium", "status": "open", "area": "hiring" }, - { "type": "Issue", "id": "competitor-pricing", "title": "Competitor undercut on entry tier", "severity": "medium", "status": "open", "area": "gtm" }, - { "type": "Issue", "id": "snapshot-bloat", "title": "Snapshot storage bloat in long-lived branches", "severity": "low", "status": "open", "area": "storage" }, - { "type": "Issue", "id": "policy-drift", "title": "Cedar policy drifts between envs", "severity": "medium", "status": "open", "area": "security" }, - - { "type": "Policy", "id": "policy-data-residency", "title": "Data Residency", "version": "v2", "applies_to": "all-customers" }, - { "type": "Policy", "id": "policy-oss", "title": "Open-Source Contributions", "version": "v1", "applies_to": "engineering" }, - { "type": "Policy", "id": "policy-hiring-rubric", "title": "Engineering Hiring Rubric", "version": "v3", "applies_to": "engineering" }, - { "type": "Policy", "id": "policy-secrets", "title": "Secrets Handling", "version": "v2", "applies_to": "all-staff" }, - { "type": "Policy", "id": "policy-customer-data", "title": "Customer Data Retention", "version": "v1", "applies_to": "all-customers" }, - - { "type": "Reference", "id": "rfc-001-engine-arch", "title": "RFC-001: Engine Architecture", "kind": "rfc", "status": "accepted", "url": "internal://rfcs/001" }, - { "type": "Reference", "id": "rfc-014-mr847", "title": "RFC-014: Manifest Recovery (MR-847)", "kind": "rfc", "status": "accepted", "url": "internal://rfcs/014" }, - { "type": "Reference", "id": "rfc-022-rrf-tuning", "title": "RFC-022: RRF Tuning", "kind": "rfc", "status": "draft", "url": "internal://rfcs/022" }, - { "type": "Reference", "id": "spec-cedar-policy", "title": "Cedar Policy Spec v3", "kind": "spec", "status": "accepted", "url": "internal://specs/cedar-v3" }, - { "type": "Reference", "id": "rfc-031-pricing", "title": "RFC-031: Pricing Tier Design", "kind": "rfc", "status": "draft", "url": "internal://rfcs/031" }, - { "type": "Reference", "id": "tpl-decision-record", "title": "Decision Record Template", "kind": "template", "status": "accepted", "url": "internal://templates/dr" }, - - { "type": "Source", "id": "src-gartner-q1", "title": "Gartner Q1 2026 Magic Quadrant", "kind": "analyst_report", "observed_at": "2026-02-01", "url": "https://example.com/gartner-q1" }, - { "type": "Source", "id": "src-customer-acme", "title": "Customer interview: ACME Corp", "kind": "customer_interview","observed_at": "2026-03-04", "url": "internal://interviews/acme" }, - { "type": "Source", "id": "src-news-competitor", "title": "TechCrunch: Competitor X funding", "kind": "news", "observed_at": "2026-03-19", "url": "https://example.com/news/comp-x" }, - { "type": "Source", "id": "src-customer-globex", "title": "Customer interview: Globex Mfg", "kind": "customer_interview","observed_at": "2026-03-22", "url": "internal://interviews/globex" }, - { "type": "Source", "id": "src-forrester", "title": "Forrester Wave: Knowledge Graphs", "kind": "analyst_report", "observed_at": "2026-04-09", "url": "https://example.com/forrester" }, - { "type": "Source", "id": "src-blog-eu-regulator","title": "Blog: EU AI regulation timeline", "kind": "blog", "observed_at": "2026-04-14", "url": "https://example.com/eu-blog" }, - - { "type": "Signal", "id": "sig-private-ai-platform", "title": "Competitor launches private AI platform for regulated industries", "category": "competitor", "strength": "strong", "observed_at": "2026-03-19" }, - { "type": "Signal", "id": "sig-enterprise-data-residency","title": "Enterprise prospects demand EU residency", "category": "customer", "strength": "strong", "observed_at": "2026-03-22" }, - { "type": "Signal", "id": "sig-eu-ai-act", "title": "EU AI Act enforcement begins Q3", "category": "regulatory", "strength": "strong", "observed_at": "2026-04-14" }, - { "type": "Signal", "id": "sig-lance-5-release", "title": "Lance 5.x release imminent; 4.x EOL plan", "category": "technology", "strength": "moderate", "observed_at": "2026-04-21" }, - { "type": "Signal", "id": "sig-customer-pricing", "title": "Customer pricing feedback at user conf", "category": "customer", "strength": "moderate", "observed_at": "2026-03-04" }, - { "type": "Signal", "id": "sig-competitor-undercut", "title": "Competitor X undercuts entry-tier pricing 30%", "category": "competitor", "strength": "strong", "observed_at": "2026-04-09" }, - { "type": "Signal", "id": "sig-soc2-blocker", "title": "SOC2 cited by 3 enterprise prospects as blocker", "category": "customer", "strength": "strong", "observed_at": "2026-02-10" }, - { "type": "Signal", "id": "sig-hnsw-improvements", "title": "HNSW recall improvements in lance-vector", "category": "technology", "strength": "weak", "observed_at": "2026-04-21" }, - - { "type": "PolicyClause", "id": "pdr-c1", "policy_id": "policy-data-residency", "title": "Region locality", "text": "All customer data resides in the customer's selected region; no implicit cross-region replication.", "status": "draft" }, - { "type": "PolicyClause", "id": "pdr-c2", "policy_id": "policy-data-residency", "title": "Cross-region transfers", "text": "Cross-region data transfers require legal review and documented customer consent.", "status": "draft" }, - { "type": "PolicyClause", "id": "pdr-c3", "policy_id": "policy-data-residency", "title": "EU territoriality", "text": "EU customer data and derived artifacts never leave EU territory under any circumstance.", "status": "draft" }, - { "type": "PolicyClause", "id": "pdr-c4", "policy_id": "policy-data-residency", "title": "Region tags in audit logs","text": "All audit log entries include the region tag of the originating data and the actor's region.", "status": "draft" }, - - { "type": "PolicyClause", "id": "poss-c1", "policy_id": "policy-oss", "title": "Outbound contributions", "text": "All OSS contributions are reviewed by the OSS committee before being made on company time.", "status": "draft" }, - { "type": "PolicyClause", "id": "poss-c2", "policy_id": "policy-oss", "title": "20% time", "text": "Engineers may contribute to upstream dependencies during their 20% time without prior approval.", "status": "draft" }, - { "type": "PolicyClause", "id": "poss-c3", "policy_id": "policy-oss", "title": "Default outbound license", "text": "Apache-2.0 is the default outbound license; deviations require legal sign-off.", "status": "draft" }, - - { "type": "PolicyClause", "id": "phr-c1", "policy_id": "policy-hiring-rubric", "title": "Two technical interviewers","text": "Each candidate has at least two independent technical interviewers from different teams.", "status": "draft" }, - { "type": "PolicyClause", "id": "phr-c2", "policy_id": "policy-hiring-rubric", "title": "Structured rubric", "text": "Interviewers score against the structured rubric (≥5 dimensions) before debrief.", "status": "draft" }, - { "type": "PolicyClause", "id": "phr-c3", "policy_id": "policy-hiring-rubric", "title": "48-hour decision SLA", "text": "Hire/no-hire decisions are documented within 48 hours of the final interview.", "status": "draft" }, - { "type": "PolicyClause", "id": "phr-c4", "policy_id": "policy-hiring-rubric", "title": "Calibration cadence", "text": "A hiring calibration session runs weekly while any req is open on the team.", "status": "draft" }, - - { "type": "PolicyClause", "id": "psec-c1", "policy_id": "policy-secrets", "title": "No secrets in source", "text": "Secrets must not appear in code, environment files, or tickets.", "status": "draft" }, - { "type": "PolicyClause", "id": "psec-c2", "policy_id": "policy-secrets", "title": "90-day rotation", "text": "All long-lived secrets are rotated at least every 90 days.", "status": "draft" }, - { "type": "PolicyClause", "id": "psec-c3", "policy_id": "policy-secrets", "title": "Quarterly audit", "text": "Secret inventory is audited quarterly by the security team with a report posted to the team channel.","status": "draft" }, - { "type": "PolicyClause", "id": "psec-c4", "policy_id": "policy-secrets", "title": "Compromise SLA", "text": "Compromised secrets are rotated within 1 hour of confirmed compromise.", "status": "draft" }, - - { "type": "PolicyClause", "id": "pcd-c1", "policy_id": "policy-customer-data", "title": "7-year retention", "text": "Customer data is retained for 7 years post-contract unless a deletion request is honored.", "status": "draft" }, - { "type": "PolicyClause", "id": "pcd-c2", "policy_id": "policy-customer-data", "title": "Right to be forgotten", "text": "Right-to-be-forgotten requests are honored within 30 days of receipt.", "status": "draft" }, - { "type": "PolicyClause", "id": "pcd-c3", "policy_id": "policy-customer-data", "title": "Encryption baseline", "text": "Customer data is encrypted at rest (AES-256+) and in transit (TLS 1.3+).", "status": "draft" }, - { "type": "PolicyClause", "id": "pcd-c4", "policy_id": "policy-customer-data", "title": "Quarterly inventory", "text": "Quarterly data inventory review with a written attestation by the data steward.", "status": "draft" } - ], - - "edges": [ - { "type": "owns", "from": "andrew", "to": "hire-vp-marketing" }, - { "type": "owns", "from": "andrew", "to": "open-source-engine" }, - { "type": "owns", "from": "priya", "to": "lance-4-migration" }, - { "type": "owns", "from": "priya", "to": "lance-5-roadmap" }, - { "type": "owns", "from": "elena", "to": "adopt-soc2" }, - { "type": "owns", "from": "elena", "to": "eu-data-residency" }, - { "type": "owns", "from": "sarah", "to": "q3-pricing-revamp" }, - { "type": "owns", "from": "miguel", "to": "zero-retention-mode" }, - { "type": "owns", "from": "james", "to": "private-ai-offering" }, - - { "type": "owns_policy", "from": "elena", "to": "policy-data-residency" }, - { "type": "owns_policy", "from": "elena", "to": "policy-secrets" }, - { "type": "owns_policy", "from": "andrew", "to": "policy-oss" }, - { "type": "owns_policy", "from": "priya", "to": "policy-hiring-rubric" }, - { "type": "owns_policy", "from": "elena", "to": "policy-customer-data" }, - - { "type": "authored", "from": "priya", "to": "rfc-001-engine-arch" }, - { "type": "authored", "from": "priya", "to": "rfc-014-mr847" }, - { "type": "authored", "from": "ruben", "to": "rfc-022-rrf-tuning" }, - { "type": "authored", "from": "elena", "to": "spec-cedar-policy" }, - { "type": "authored", "from": "sarah", "to": "rfc-031-pricing" }, - { "type": "authored", "from": "andrew", "to": "tpl-decision-record" }, - { "type": "authored", "from": "taro", "to": "rfc-001-engine-arch" }, - { "type": "authored", "from": "taro", "to": "rfc-014-mr847" }, - - { "type": "participated", "from": "priya", "to": "adopt-soc2" }, - { "type": "participated", "from": "elena", "to": "open-source-engine" }, - { "type": "participated", "from": "miguel", "to": "open-source-engine" }, - { "type": "participated", "from": "james", "to": "hire-vp-marketing" }, - { "type": "participated", "from": "sarah", "to": "hire-vp-marketing" }, - { "type": "participated", "from": "andrew", "to": "private-ai-offering" }, - { "type": "participated", "from": "priya", "to": "private-ai-offering" }, - { "type": "participated", "from": "elena", "to": "private-ai-offering" }, - { "type": "participated", "from": "andrew", "to": "eu-data-residency" }, - { "type": "participated", "from": "james", "to": "eu-data-residency" }, - { "type": "participated", "from": "james", "to": "zero-retention-mode" }, - { "type": "participated", "from": "elena", "to": "zero-retention-mode" }, - { "type": "participated", "from": "ruben", "to": "lance-4-migration" }, - { "type": "participated", "from": "taro", "to": "lance-4-migration" }, - { "type": "participated", "from": "ruben", "to": "lance-5-roadmap" }, - { "type": "participated", "from": "taro", "to": "lance-5-roadmap" }, - { "type": "participated", "from": "james", "to": "q3-pricing-revamp" }, - { "type": "participated", "from": "andrew", "to": "q3-pricing-revamp" }, - - { "type": "triggers", "from": "sig-private-ai-platform", "to": "private-ai-offering" }, - { "type": "triggers", "from": "sig-enterprise-data-residency","to": "eu-data-residency" }, - { "type": "triggers", "from": "sig-eu-ai-act", "to": "eu-data-residency" }, - { "type": "triggers", "from": "sig-lance-5-release", "to": "lance-5-roadmap" }, - { "type": "triggers", "from": "sig-customer-pricing", "to": "q3-pricing-revamp" }, - { "type": "triggers", "from": "sig-competitor-undercut", "to": "q3-pricing-revamp" }, - { "type": "triggers", "from": "sig-soc2-blocker", "to": "adopt-soc2" }, - { "type": "triggers", "from": "sig-hnsw-improvements", "to": "lance-5-roadmap" }, - { "type": "triggers", "from": "sig-soc2-blocker", "to": "eu-data-residency" }, - - { "type": "substantiates", "from": "rfc-001-engine-arch", "to": "open-source-engine" }, - { "type": "substantiates", "from": "rfc-014-mr847", "to": "lance-4-migration" }, - { "type": "substantiates", "from": "rfc-014-mr847", "to": "lance-5-roadmap" }, - { "type": "substantiates", "from": "rfc-022-rrf-tuning", "to": "lance-5-roadmap" }, - { "type": "substantiates", "from": "spec-cedar-policy", "to": "adopt-soc2" }, - { "type": "substantiates", "from": "spec-cedar-policy", "to": "eu-data-residency" }, - { "type": "substantiates", "from": "rfc-031-pricing", "to": "q3-pricing-revamp" }, - { "type": "substantiates", "from": "tpl-decision-record", "to": "adopt-soc2" }, - { "type": "substantiates", "from": "tpl-decision-record", "to": "eu-data-residency" }, - { "type": "substantiates", "from": "tpl-decision-record", "to": "open-source-engine" }, - - { "type": "observed_via", "from": "sig-private-ai-platform", "to": "src-news-competitor" }, - { "type": "observed_via", "from": "sig-enterprise-data-residency","to": "src-customer-acme" }, - { "type": "observed_via", "from": "sig-enterprise-data-residency","to": "src-customer-globex" }, - { "type": "observed_via", "from": "sig-eu-ai-act", "to": "src-blog-eu-regulator" }, - { "type": "observed_via", "from": "sig-lance-5-release", "to": "src-news-competitor" }, - { "type": "observed_via", "from": "sig-customer-pricing", "to": "src-customer-acme" }, - { "type": "observed_via", "from": "sig-competitor-undercut", "to": "src-gartner-q1" }, - { "type": "observed_via", "from": "sig-soc2-blocker", "to": "src-customer-globex" }, - { "type": "observed_via", "from": "sig-hnsw-improvements", "to": "src-news-competitor" }, - - { "type": "raises", "from": "fts-recall-degradation", "to": "lance-5-roadmap" }, - { "type": "raises", "from": "cold-start-latency", "to": "lance-5-roadmap" }, - { "type": "raises", "from": "branch-merge-conflicts", "to": "lance-5-roadmap" }, - { "type": "raises", "from": "enterprise-deal-stalled", "to": "eu-data-residency" }, - { "type": "raises", "from": "hiring-pipeline-thin", "to": "hire-vp-marketing" }, - { "type": "raises", "from": "competitor-pricing", "to": "q3-pricing-revamp" }, - { "type": "raises", "from": "policy-drift", "to": "adopt-soc2" }, - - { "type": "resolves", "from": "adopt-soc2", "to": "policy-drift" }, - { "type": "resolves", "from": "eu-data-residency", "to": "enterprise-deal-stalled" }, - { "type": "resolves", "from": "lance-5-roadmap", "to": "cold-start-latency" }, - { "type": "resolves", "from": "lance-5-roadmap", "to": "branch-merge-conflicts" }, - { "type": "resolves", "from": "q3-pricing-revamp", "to": "competitor-pricing" }, - - { "type": "affects", "from": "adopt-soc2", "to": "snapshot-bloat" }, - { "type": "affects", "from": "open-source-engine", "to": "snapshot-bloat" }, - { "type": "affects", "from": "private-ai-offering", "to": "cold-start-latency" }, - { "type": "affects", "from": "private-ai-offering", "to": "snapshot-bloat" }, - { "type": "affects", "from": "zero-retention-mode", "to": "policy-drift" }, - { "type": "affects", "from": "lance-4-migration", "to": "fts-recall-degradation" }, - { "type": "affects", "from": "lance-5-roadmap", "to": "fts-recall-degradation" }, - { "type": "affects", "from": "eu-data-residency", "to": "snapshot-bloat" }, - { "type": "affects", "from": "q3-pricing-revamp", "to": "hiring-pipeline-thin" }, - - { "type": "governed_by", "from": "adopt-soc2", "to": "policy-secrets" }, - { "type": "governed_by", "from": "adopt-soc2", "to": "policy-customer-data" }, - { "type": "governed_by", "from": "eu-data-residency", "to": "policy-data-residency" }, - { "type": "governed_by", "from": "eu-data-residency", "to": "policy-customer-data" }, - { "type": "governed_by", "from": "private-ai-offering", "to": "policy-data-residency" }, - { "type": "governed_by", "from": "zero-retention-mode", "to": "policy-customer-data" }, - { "type": "governed_by", "from": "open-source-engine", "to": "policy-oss" }, - { "type": "governed_by", "from": "hire-vp-marketing", "to": "policy-hiring-rubric" }, - - { "type": "supersedes", "from": "lance-5-roadmap", "to": "lance-4-migration" }, - - { "type": "cites", "from": "rfc-014-mr847", "to": "rfc-001-engine-arch" }, - { "type": "cites", "from": "rfc-022-rrf-tuning", "to": "rfc-001-engine-arch" }, - { "type": "cites", "from": "rfc-031-pricing", "to": "tpl-decision-record" }, - { "type": "cites", "from": "rfc-022-rrf-tuning", "to": "rfc-014-mr847" }, - { "type": "cites", "from": "spec-cedar-policy", "to": "rfc-001-engine-arch" }, - { "type": "cites", "from": "tpl-decision-record", "to": "rfc-001-engine-arch" }, - { "type": "cites", "from": "rfc-031-pricing", "to": "rfc-001-engine-arch" }, - - { "type": "discussed_in", "from": "private-ai-offering", "to": "src-customer-acme" }, - { "type": "discussed_in", "from": "eu-data-residency", "to": "src-customer-globex" }, - { "type": "discussed_in", "from": "q3-pricing-revamp", "to": "src-customer-acme" }, - { "type": "discussed_in", "from": "adopt-soc2", "to": "src-forrester" }, - { "type": "discussed_in", "from": "open-source-engine", "to": "src-news-competitor" }, - - { "type": "hasClause", "from": "policy-data-residency", "to": "pdr-c1" }, - { "type": "hasClause", "from": "policy-data-residency", "to": "pdr-c2" }, - { "type": "hasClause", "from": "policy-data-residency", "to": "pdr-c3" }, - { "type": "hasClause", "from": "policy-data-residency", "to": "pdr-c4" }, - - { "type": "hasClause", "from": "policy-oss", "to": "poss-c1" }, - { "type": "hasClause", "from": "policy-oss", "to": "poss-c2" }, - { "type": "hasClause", "from": "policy-oss", "to": "poss-c3" }, - - { "type": "hasClause", "from": "policy-hiring-rubric", "to": "phr-c1" }, - { "type": "hasClause", "from": "policy-hiring-rubric", "to": "phr-c2" }, - { "type": "hasClause", "from": "policy-hiring-rubric", "to": "phr-c3" }, - { "type": "hasClause", "from": "policy-hiring-rubric", "to": "phr-c4" }, - - { "type": "hasClause", "from": "policy-secrets", "to": "psec-c1" }, - { "type": "hasClause", "from": "policy-secrets", "to": "psec-c2" }, - { "type": "hasClause", "from": "policy-secrets", "to": "psec-c3" }, - { "type": "hasClause", "from": "policy-secrets", "to": "psec-c4" }, - - { "type": "hasClause", "from": "policy-customer-data", "to": "pcd-c1" }, - { "type": "hasClause", "from": "policy-customer-data", "to": "pcd-c2" }, - { "type": "hasClause", "from": "policy-customer-data", "to": "pcd-c3" }, - { "type": "hasClause", "from": "policy-customer-data", "to": "pcd-c4" } - ] -} diff --git a/packages/cli/package.json b/packages/cli/package.json index 01c7796..82acf15 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,7 +33,6 @@ "devDependencies": { "@modernrelay/notebook-core": "workspace:*", "@modernrelay/notebook-client": "workspace:*", - "@modernrelay/notebook-fixture": "workspace:*", "@modernrelay/notebook-tui": "workspace:*", "@modernrelay/notebook-web": "workspace:*", "tsup": "^8.5.0", diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 78d3b2b..40e27b8 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -40,13 +40,11 @@ export function validateCommand(argv: string[]): number { return 1; } - // Structural parse passed. Capability-check against a source when the notebook - // (or a --server flag) points at one — this also loads + validates the fixture. + // Structural parse passed. Capability-check against the source when the + // notebook (or a --server flag) points at an omnigraph-server. const result: ValidateResult = { ok: true, errors: [] }; const sourceOpts = sourceOptionsFrom(values); - const hasSource = Boolean( - loaded.notebook.fixture || loaded.notebook.server || sourceOpts.server, - ); + const hasSource = Boolean(loaded.notebook.server || sourceOpts.server); if (hasSource) { try { const { source } = buildSource(loaded, sourceOpts); diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index 061416b..f55ef01 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -1,12 +1,10 @@ import { spawn } from "node:child_process"; -import { createReadStream, existsSync, readFileSync } from "node:fs"; +import { createReadStream, existsSync } from "node:fs"; import http from "node:http"; import type { AddressInfo } from "node:net"; import { dirname, extname, join, normalize, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { parseNotebook } from "@modernrelay/notebook-core"; - import { proxyOg } from "./proxy.js"; import type { Connection } from "./source.js"; @@ -48,28 +46,7 @@ export interface ServeOptions { export async function serve(opts: ServeOptions): Promise { const webDist = resolveWebDist(); - const notebook = parseNotebook(readFileSync(opts.notebookPath, "utf8")); - - // Fixture mode: the SPA resolves `notebook.fixture` against `/notebook.yaml` - // and fetches that URL path — bind exactly that path to the on-disk fixture. - let fixtureUrlPath: string | undefined; - let fixtureFile: string | undefined; - if (opts.connection.mode === "fixture") { - if (!notebook.fixture) { - throw new Error("fixture mode requires `fixture:` in the notebook"); - } - // Decode so it compares against the request's decoded path (a fixture name - // with e.g. a space arrives URL-encoded from the browser's fetch). - fixtureUrlPath = decodeURIComponent( - new URL(notebook.fixture, "http://x/notebook.yaml").pathname, - ); - fixtureFile = resolve(dirname(opts.notebookPath), notebook.fixture); - } - - const upstream = - opts.connection.mode === "server" && opts.connection.server - ? new URL(opts.connection.server) - : undefined; + const upstream = new URL(opts.connection.server); const server = http.createServer((req, res) => { const rawPath = (req.url ?? "/").split("?")[0] ?? "/"; @@ -86,11 +63,6 @@ export async function serve(opts: ServeOptions): Promise { // 1. /og/* → BFF reverse proxy (token injected server-side). if (path === "/og" || path.startsWith("/og/")) { - if (!upstream) { - res.writeHead(502, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "no upstream server configured" })); - return; - } proxyOg(req, res, upstream, opts.connection.token); return; } @@ -99,18 +71,13 @@ export async function serve(opts: ServeOptions): Promise { sendFile(res, opts.notebookPath, ".yaml"); return; } - // 3. the fixture file (fixture mode only) - if (fixtureUrlPath && fixtureFile && path === fixtureUrlPath) { - sendFile(res, fixtureFile, ".json"); - return; - } - // 4. real static files under web-dist + // 3. real static files under web-dist const staticPath = safeJoin(webDist, path); if (staticPath && !path.endsWith("/") && existsSync(staticPath)) { sendFile(res, staticPath, extname(staticPath)); return; } - // 5. SPA fallback + // 4. SPA fallback sendFile(res, join(webDist, "index.html"), ".html"); }); @@ -141,13 +108,10 @@ export async function serve(opts: ServeOptions): Promise { function buildOpenUrl(port: number, conn: Connection): string { const params = new URLSearchParams(); - params.set("mode", conn.mode); params.set("notebook", "/notebook.yaml"); - if (conn.mode === "server") { - params.set("server", "/og"); // same-origin via the proxy above - if (conn.graphId) params.set("graph", conn.graphId); - if (conn.branch) params.set("branch", conn.branch); - } + params.set("server", "/og"); // same-origin via the proxy above + params.set("graph", conn.graphId); + if (conn.branch) params.set("branch", conn.branch); return `http://127.0.0.1:${port}/?${params.toString()}`; } diff --git a/packages/cli/src/source.ts b/packages/cli/src/source.ts index da258a8..801d3d1 100644 --- a/packages/cli/src/source.ts +++ b/packages/cli/src/source.ts @@ -1,9 +1,7 @@ import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { resolve } from "node:path"; import { Client, ServerSource } from "@modernrelay/notebook-client"; -import { FixtureSource } from "@modernrelay/notebook-fixture"; -import { loadFixture } from "@modernrelay/notebook-fixture/node"; import { parseNotebook, type Notebook } from "@modernrelay/notebook-core"; import type { Source } from "@modernrelay/notebook-core"; @@ -29,37 +27,28 @@ export function loadNotebook(notebookPath: string): LoadedNotebook { /** Resolved connection params — the single source of truth for source selection. */ export interface Connection { - mode: "fixture" | "server"; - /** Upstream omnigraph-server URL (server mode). */ - server?: string; + /** Upstream omnigraph-server URL. */ + server: string; token?: string; - graphId?: string; + graphId: string; branch?: string; label: string; } /** - * Resolve fixture-vs-server + graph/token/branch from a notebook + CLI/env - * options — the same selection the TUI uses (packages/tui/src/index.tsx). - * Fixture wins when the notebook declares `fixture:`. Server-mode graph-id - * precedence: flag → $OMNIGRAPH_GRAPH_ID → notebook. No I/O. + * Resolve the omnigraph-server connection from a notebook + CLI/env options — + * the same selection the TUI uses (packages/tui/src/index.tsx). Server-mode + * graph-id precedence: flag → $OMNIGRAPH_GRAPH_ID → notebook. No I/O. */ export function resolveConnection( loaded: LoadedNotebook, opts: SourceOptions, ): Connection { const { notebook } = loaded; - if (notebook.fixture) { - return { - mode: "fixture", - label: `fixture: ${notebook.fixture}`, - ...(opts.branch !== undefined ? { branch: opts.branch } : {}), - }; - } const server = opts.server ?? notebook.server; if (!server) { throw new Error( - "notebook has neither `fixture:` nor `server:` (and no --server given)", + "notebook has no `server:` (and no --server given)", ); } const token = @@ -74,7 +63,6 @@ export function resolveConnection( ); } return { - mode: "server", server, graphId, label: `server: ${server} · graph: ${graphId}`, @@ -89,25 +77,17 @@ export interface BuiltSource { } /** - * Build a runtime `Source` from a loaded notebook + options. Fixture mode loads - * the fixture JSON from disk; server mode constructs a ServerSource (no I/O — - * reads happen lazily). + * Build a runtime `Source` from a loaded notebook + options. Constructs a + * `ServerSource` over the omnigraph SDK (no I/O — reads happen lazily). */ export function buildSource( loaded: LoadedNotebook, opts: SourceOptions, ): BuiltSource { const connection = resolveConnection(loaded, opts); - if (connection.mode === "fixture") { - const fixturePath = resolve( - dirname(loaded.notebookPath), - loaded.notebook.fixture as string, - ); - return { source: new FixtureSource(loadFixture(fixturePath)), connection }; - } const client = new Client({ - baseUrl: connection.server as string, - graphId: connection.graphId as string, + baseUrl: connection.server, + graphId: connection.graphId, ...(connection.token !== undefined ? { token: connection.token } : {}), }); const source = new ServerSource(client, { diff --git a/packages/client/src/translate.ts b/packages/client/src/translate.ts index 2ba4079..4e193b5 100644 --- a/packages/client/src/translate.ts +++ b/packages/client/src/translate.ts @@ -1,7 +1,8 @@ /** - * Pure translators from the fixture-DSL (the same DSL that powers - * `@modernrelay/notebook-fixture`'s in-memory runner) into the parameterized `.gq` - * source that omnigraph-server speaks. + * Pure translators from the structured query DSL (cells' `query.fixture`: + * nodes/path/ego) into the parameterized `.gq` source that omnigraph-server + * speaks. Interim — to be superseded by predefined-query references + * (`query.ref`); see dash-books-canon.md §4. * * Output shape for every translator: * { query_source: string, query_name: string, params: Record } diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index c79491a..096a3f6 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -102,8 +102,8 @@ export const lensActions = { description: "Write a value to the state model at the given JSON pointer.", }, /** - * Atomic mutation against the underlying source (FixtureSource in dev, - * HTTP client to omnigraph-server in prod). Each invocation is one + * Atomic mutation against omnigraph-server (via the @modernrelay/omnigraph + * SDK). Each invocation is one * commit. The cell author declares the mutation shape via * ActionList.actions[*].mutation; the lens fills target_id from the * row at click time. diff --git a/packages/core/src/catalog/lenses/action_list.ts b/packages/core/src/catalog/lenses/action_list.ts index 6a9e7c4..81c4b66 100644 --- a/packages/core/src/catalog/lenses/action_list.ts +++ b/packages/core/src/catalog/lenses/action_list.ts @@ -15,8 +15,8 @@ const ActionDescriptorSchema = z /** * Declarative mutation spec. When set, the lens fires the built-in * `mutate` action with `{ ...mutation, target_id: row[id_column] }`. - * The substrate (FixtureSource in dev, omnigraph-server `POST /change` - * in prod) executes one atomic mutation per click. + * omnigraph-server (`POST /change`, via the SDK) executes one atomic + * mutation per click. */ mutation: MutationSpecSchema.optional(), }) diff --git a/packages/core/src/spec/index.test.ts b/packages/core/src/spec/index.test.ts index 76f05d9..52682f3 100644 --- a/packages/core/src/spec/index.test.ts +++ b/packages/core/src/spec/index.test.ts @@ -2,11 +2,12 @@ import { describe, it, expect } from "vitest"; import { parseNotebook } from "./index.js"; describe("parseNotebook", () => { - it("parses a fixture-mode notebook", () => { + it("parses a server-mode notebook", () => { const yaml = ` version: 1 title: Demo -fixture: ./fixtures/x.json +server: http://127.0.0.1:8080 +graph: company cells: - id: t lens: Table @@ -15,7 +16,8 @@ cells: props: { columns: [{ key: id, label: ID }] } `; const nb = parseNotebook(yaml); - expect(nb.fixture).toBe("./fixtures/x.json"); + expect(nb.server).toBe("http://127.0.0.1:8080"); + expect(nb.graph).toBe("company"); expect(nb.cells).toHaveLength(1); expect(nb.cells[0]?.query.fixture?.kind).toBe("nodes"); }); diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 0ee7b04..7c40a85 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -30,8 +30,8 @@ export type ActionBinding = z.infer; // ── Mutation DSL ────────────────────────────────────────────────────────── // Declarative atomic mutations dispatched by ActionList per-row buttons. -// The substrate (FixtureSource in dev, omnigraph-server `POST /change` in -// prod) executes one mutation per click. Cell authors declare the shape; +// The substrate (omnigraph-server `POST /change`, via the SDK) executes one +// mutation per click. Cell authors declare the shape; // the lens fills `target_id` from the row at click time. const SetFieldMutationSchema = z.object({ @@ -59,10 +59,11 @@ export interface MutationResult { kind: "ok"; } -// ── Fixture-mode query DSL ──────────────────────────────────────────────── -// Used when a notebook declares a top-level `fixture` path. The cell's -// `query.fixture` carries a structured query that the in-memory runner -// evaluates against the loaded JSON graph. See @modernrelay/notebook-fixture. +// ── Structured query DSL (interim) ──────────────────────────────────────── +// A cell's `query.fixture` carries a structured query (nodes/path/ego) that +// `@modernrelay/notebook-client` compiles to `.gq` for omnigraph-server. The +// `fixture` name is historical; this is slated to become a predefined-query +// reference (`query.ref`). See dash-books-canon.md §4. const FixtureNodesQuerySchema = z.object({ kind: z.literal("nodes"), @@ -152,7 +153,8 @@ const QuerySchema = z params: z.record(z.string(), z.unknown()).optional(), branch: z.string().optional(), snapshot: z.string().optional(), - // Fixture mode — used when notebook declares a top-level `fixture`. + // Structured query DSL (nodes/path/ego), compiled to `.gq` for the + // server. Interim — to become a predefined-query `ref` (canon §4). fixture: FixtureQuerySchema.optional(), }) .refine( @@ -227,8 +229,6 @@ export type Cell = z.infer; export const NotebookSchema = z.object({ version: z.literal(1), title: z.string().min(1), - /** Path to a JSON fixture (relative to the notebook). When set, runs in fixture mode. */ - fixture: z.string().min(1).optional(), /** omnigraph-server base URL when running in server mode. CLI flag overrides. */ server: z.url().optional(), /** diff --git a/packages/fixture/package.json b/packages/fixture/package.json deleted file mode 100644 index b81044c..0000000 --- a/packages/fixture/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@modernrelay/notebook-fixture", - "version": "0.0.1", - "private": true, - "description": "In-memory graph fixture loader + structured query runner. Drop-in for a live engine when iterating on UI.", - "type": "module", - "sideEffects": false, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./node": { - "import": "./dist/loader.js", - "types": "./dist/loader.d.ts" - } - }, - "files": ["dist"], - "scripts": { - "build": "tsc", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@modernrelay/notebook-core": "workspace:*", - "zod": "^4.4.3" - }, - "devDependencies": { - "vitest": "^4.1.5" - } -} diff --git a/packages/fixture/src/index.ts b/packages/fixture/src/index.ts deleted file mode 100644 index 9cf2078..0000000 --- a/packages/fixture/src/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Browser-safe surface. Node-only `loadFixture` lives at -// `@modernrelay/notebook-fixture/node` so bundlers don't pull in `node:fs` / `node:path`. -export { - parseFixture, - FixtureSchema, - FixtureNodeSchema, - FixtureEdgeSchema, - type Fixture, - type FixtureNode, - type FixtureEdge, -} from "./validator.js"; - -export { - runFixtureQuery, - type QueryResult, - type ResultRow, -} from "./runner.js"; - -export { - FixtureSource, - type FixtureReadInput, - type FixtureReadOutput, -} from "./source.js"; diff --git a/packages/fixture/src/loader.test.ts b/packages/fixture/src/loader.test.ts deleted file mode 100644 index 4547e8d..0000000 --- a/packages/fixture/src/loader.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { loadFixture } from "./loader.js"; - -function tmpFile(content: unknown): string { - const dir = mkdtempSync(join(tmpdir(), "omnigraph-fixture-")); - const path = join(dir, "f.json"); - writeFileSync(path, JSON.stringify(content)); - return path; -} - -describe("loadFixture", () => { - it("loads a valid fixture", () => { - const path = tmpFile({ - version: 1, - title: "ok", - nodes: [ - { type: "Actor", id: "a" }, - { type: "Decision", id: "d" }, - ], - edges: [{ type: "owns", from: "a", to: "d" }], - }); - expect(loadFixture(path).nodes).toHaveLength(2); - }); - - it("rejects orphan edges", () => { - const path = tmpFile({ - version: 1, - title: "bad", - nodes: [{ type: "Actor", id: "a" }], - edges: [{ type: "owns", from: "a", to: "missing" }], - }); - expect(() => loadFixture(path)).toThrow(/unknown nodes/); - }); - - it("rejects duplicate node ids", () => { - const path = tmpFile({ - version: 1, - title: "bad", - nodes: [ - { type: "Actor", id: "a" }, - { type: "Decision", id: "a" }, - ], - edges: [], - }); - expect(() => loadFixture(path)).toThrow(/duplicate/); - }); - - it("loads the company-context fixture", () => { - const path = fileURLToPath( - new URL( - "../../../examples/fixtures/company-context.json", - import.meta.url, - ), - ); - const fix = loadFixture(path); - expect(fix.nodes.length).toBe(69); - expect(fix.edges.length).toBe(129); - }); -}); diff --git a/packages/fixture/src/loader.ts b/packages/fixture/src/loader.ts deleted file mode 100644 index 2224418..0000000 --- a/packages/fixture/src/loader.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { parseFixture, type Fixture } from "./validator.js"; - -/** - * Load and validate a JSON fixture from disk. Node-only (uses fs). - * For browsers, import the JSON some other way and call `parseFixture`. - */ -export function loadFixture(path: string): Fixture { - const abs = resolve(path); - const raw: unknown = JSON.parse(readFileSync(abs, "utf8")); - return parseFixture(raw, path); -} diff --git a/packages/fixture/src/runner.test.ts b/packages/fixture/src/runner.test.ts deleted file mode 100644 index 53be792..0000000 --- a/packages/fixture/src/runner.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { Fixture } from "./validator.js"; -import { runFixtureQuery } from "./runner.js"; - -const FIX: Fixture = { - version: 1, - title: "tiny", - nodes: [ - { type: "Actor", id: "a1", name: "Andrew" }, - { type: "Actor", id: "a2", name: "Bruno" }, - { type: "Decision", id: "d1", title: "Adopt SOC2", urgency: "high" }, - { type: "Decision", id: "d2", title: "Fix bug", urgency: "low" }, - { type: "Signal", id: "s1", title: "Compliance need" }, - ], - edges: [ - { type: "owns", from: "a1", to: "d1" }, - { type: "owns", from: "a2", to: "d2" }, - { type: "triggers", from: "s1", to: "d1" }, - ], -}; - -describe("runFixtureQuery / nodes", () => { - it("filters by where", () => { - const r = runFixtureQuery({ kind: "nodes", where: { type: "Decision" } }, FIX); - expect(r.rows).toHaveLength(2); - }); - - it("projects requested columns only", () => { - const r = runFixtureQuery( - { kind: "nodes", where: { type: "Decision" }, project: ["id", "title"] }, - FIX, - ); - expect(r.columns).toEqual(["id", "title"]); - expect(r.rows[0]).toEqual({ id: "d1", title: "Adopt SOC2" }); - }); - - it("orders ascending and descending", () => { - const asc = runFixtureQuery( - { - kind: "nodes", - where: { type: "Decision" }, - project: ["urgency"], - order_by: { field: "urgency", direction: "asc" }, - }, - FIX, - ); - expect(asc.rows.map((r) => r.urgency)).toEqual(["high", "low"]); - const desc = runFixtureQuery( - { - kind: "nodes", - where: { type: "Decision" }, - project: ["urgency"], - order_by: { field: "urgency", direction: "desc" }, - }, - FIX, - ); - expect(desc.rows.map((r) => r.urgency)).toEqual(["low", "high"]); - }); - - it("respects limit", () => { - const r = runFixtureQuery( - { kind: "nodes", where: { type: "Decision" }, limit: 1 }, - FIX, - ); - expect(r.rows).toHaveLength(1); - }); -}); - -describe("runFixtureQuery / path", () => { - it("traverses a single edge", () => { - const r = runFixtureQuery( - { - kind: "path", - steps: [ - { var: "a", type: "Actor" }, - { edge: "owns", var: "d", type: "Decision" }, - ], - project: [ - { var: "a.name", as: "actor" }, - { literal: "owns", as: "p" }, - { var: "d.title", as: "decision" }, - ], - }, - FIX, - ); - expect(r.rows).toHaveLength(2); - expect(r.rows[0]).toEqual({ actor: "Andrew", p: "owns", decision: "Adopt SOC2" }); - }); - - it("traverses with direction: in (reverse)", () => { - // Decision <-owns- Actor: anchor on Decision and walk against the edge. - const r = runFixtureQuery( - { - kind: "path", - steps: [ - { var: "d", type: "Decision" }, - { edge: "owns", var: "a", type: "Actor", direction: "in" }, - ], - project: [ - { var: "d.title", as: "decision" }, - { literal: "owned by", as: "p" }, - { var: "a.name", as: "actor" }, - ], - }, - FIX, - ); - expect(r.rows).toHaveLength(2); - const decisions = r.rows.map((row) => row.decision).sort(); - expect(decisions).toEqual(["Adopt SOC2", "Fix bug"]); - }); - - it("type-filters at every step", () => { - const r = runFixtureQuery( - { - kind: "path", - steps: [ - { var: "a", type: "Actor" }, - { edge: "owns", var: "x", type: "Issue" }, // no Issue type - ], - project: [ - { var: "a.name", as: "actor" }, - { var: "x.id", as: "issue" }, - ], - }, - FIX, - ); - expect(r.rows).toHaveLength(0); - }); -}); - -describe("runFixtureQuery / ego", () => { - it("returns one row per incident edge", () => { - const r = runFixtureQuery( - { - kind: "ego", - center: { type: "Decision", where: { id: "d1" } }, - out: [], - in: ["owns", "triggers"], - project: [ - { var: "center.id", as: "id" }, - { var: "edge_type", as: "predicate" }, - { var: "neighbor.id", as: "neighbor" }, - ], - }, - FIX, - ); - expect(r.rows).toHaveLength(2); - const predicates = r.rows.map((row) => row.predicate).sort(); - expect(predicates).toEqual(["owns", "triggers"]); - }); - - it("emits a single bare-center row when no neighbors match", () => { - const r = runFixtureQuery( - { - kind: "ego", - center: { type: "Decision", where: { id: "d2" } }, - out: ["nonexistent"], - in: [], - project: [ - { var: "center.id", as: "id" }, - { var: "edge_type", as: "predicate" }, - { var: "neighbor.id", as: "neighbor" }, - ], - }, - FIX, - ); - expect(r.rows).toHaveLength(1); - expect(r.rows[0]?.predicate).toBeNull(); - }); -}); diff --git a/packages/fixture/src/runner.ts b/packages/fixture/src/runner.ts deleted file mode 100644 index d588ea3..0000000 --- a/packages/fixture/src/runner.ts +++ /dev/null @@ -1,264 +0,0 @@ -import type { - FixtureQuery, - FixtureNodesQuery, - FixturePathQuery, - FixtureEgoQuery, -} from "@modernrelay/notebook-core"; -import type { Fixture, FixtureNode, FixtureEdge } from "./validator.js"; - -export type ResultRow = Record; - -export interface QueryResult { - columns: string[]; - rows: ResultRow[]; -} - -/** - * Run a structured fixture query against the in-memory graph. Pure: takes - * the query + fixture, returns rows. No I/O, no async. - */ -export function runFixtureQuery( - query: FixtureQuery, - fixture: Fixture, -): QueryResult { - switch (query.kind) { - case "nodes": - return runNodes(query, fixture); - case "path": - return runPath(query, fixture); - case "ego": - return runEgo(query, fixture); - } -} - -// ── nodes ──────────────────────────────────────────────────────────────── - -function runNodes(q: FixtureNodesQuery, fix: Fixture): QueryResult { - let rows: FixtureNode[] = fix.nodes.filter((n) => matchesWhere(n, q.where)); - - if (q.order_by) { - const { field, direction } = q.order_by; - rows = [...rows].sort((a, b) => - compareValues(a[field], b[field], direction), - ); - } - if (q.limit !== undefined) { - rows = rows.slice(0, q.limit); - } - - const columns = q.project ?? inferColumns(rows); - const projected: ResultRow[] = rows.map((node) => { - const out: ResultRow = {}; - for (const key of columns) out[key] = node[key]; - return out; - }); - return { columns, rows: projected }; -} - -// ── path ───────────────────────────────────────────────────────────────── - -function runPath(q: FixturePathQuery, fix: Fixture): QueryResult { - const [start, ...rest] = q.steps; - if (!start) throw new Error("path query requires at least one step"); - - // Each binding is a Map representing a candidate row. - let bindings: Map[] = fix.nodes - .filter((n) => start.type === undefined || n.type === start.type) - .map((n) => new Map([[start.var, n]])); - - for (const step of rest) { - if (!step.edge) { - throw new Error( - `path step '${step.var}' is not the first step and must declare an edge`, - ); - } - const direction = step.direction ?? "out"; - const next: Map[] = []; - for (const binding of bindings) { - const sourceVar = previousVar(q.steps, step); - const anchor = binding.get(sourceVar); - if (!anchor) continue; - const matches = fix.edges.filter((e) => { - if (e.type !== step.edge) return false; - return direction === "out" ? e.from === anchor.id : e.to === anchor.id; - }); - for (const edge of matches) { - const targetId = direction === "out" ? edge.to : edge.from; - const target = fix.nodes.find((n) => n.id === targetId); - if (!target) continue; - if (step.type !== undefined && target.type !== step.type) continue; - const extended = new Map(binding); - extended.set(step.var, target); - next.push(extended); - } - } - bindings = next; - } - - const columns = q.project.map((p) => p.as); - const rows: ResultRow[] = bindings.map((binding) => { - const row: ResultRow = {}; - for (const proj of q.project) { - if (proj.literal !== undefined) { - row[proj.as] = proj.literal; - } else if (proj.var !== undefined) { - row[proj.as] = resolveVarRef(proj.var, binding); - } - } - return row; - }); - return { columns, rows }; -} - -function previousVar( - steps: FixturePathQuery["steps"], - current: FixturePathQuery["steps"][number], -): string { - const idx = steps.indexOf(current); - const prev = steps[idx - 1]; - if (!prev) { - throw new Error("path step has no previous step (internal invariant)"); - } - return prev.var; -} - -// ── ego ────────────────────────────────────────────────────────────────── - -function runEgo(q: FixtureEgoQuery, fix: Fixture): QueryResult { - const centers = fix.nodes.filter( - (n) => n.type === q.center.type && matchesWhere(n, q.center.where), - ); - - const columns = q.project.map((p) => p.as); - const rows: ResultRow[] = []; - - for (const center of centers) { - const incident: Array<{ - edge: FixtureEdge; - neighbor: FixtureNode; - direction: "out" | "in"; - }> = []; - - if (q.out.length > 0) { - for (const edge of fix.edges) { - if (edge.from !== center.id) continue; - if (!q.out.includes(edge.type)) continue; - const neighbor = fix.nodes.find((n) => n.id === edge.to); - if (neighbor) incident.push({ edge, neighbor, direction: "out" }); - } - } - if (q.in.length > 0) { - for (const edge of fix.edges) { - if (edge.to !== center.id) continue; - if (!q.in.includes(edge.type)) continue; - const neighbor = fix.nodes.find((n) => n.id === edge.from); - if (neighbor) incident.push({ edge, neighbor, direction: "in" }); - } - } - - for (const { edge, neighbor, direction } of incident) { - const row: ResultRow = {}; - for (const proj of q.project) { - row[proj.as] = resolveEgoRef(proj.var, { - center, - edge, - neighbor, - direction, - }); - } - rows.push(row); - } - - // If the center has no incident edges but the user asked for some, - // emit one bare-center row so the lens can show the focal node. - if (incident.length === 0 && (q.out.length > 0 || q.in.length > 0)) { - const row: ResultRow = {}; - for (const proj of q.project) { - row[proj.as] = resolveEgoRef(proj.var, { - center, - edge: undefined, - neighbor: undefined, - direction: undefined, - }); - } - rows.push(row); - } - } - - return { columns, rows }; -} - -// ── helpers ────────────────────────────────────────────────────────────── - -function matchesWhere( - node: FixtureNode, - where: Record | undefined, -): boolean { - if (!where) return true; - for (const [key, expected] of Object.entries(where)) { - if (node[key] !== expected) return false; - } - return true; -} - -function resolveVarRef( - ref: string, - binding: Map, -): unknown { - // Forms: "x" → bound node id; "x.field" → property access - const dot = ref.indexOf("."); - if (dot < 0) { - const node = binding.get(ref); - return node?.id ?? null; - } - const varName = ref.slice(0, dot); - const field = ref.slice(dot + 1); - const node = binding.get(varName); - if (!node) return null; - return node[field] ?? null; -} - -function resolveEgoRef( - ref: string, - ctx: { - center: FixtureNode; - edge: FixtureEdge | undefined; - neighbor: FixtureNode | undefined; - direction: "out" | "in" | undefined; - }, -): unknown { - if (ref === "edge_type") return ctx.edge?.type ?? null; - if (ref === "edge_direction") return ctx.direction ?? null; - if (ref === "neighbor_type") return ctx.neighbor?.type ?? null; - if (ref.startsWith("center.")) { - return ctx.center[ref.slice("center.".length)] ?? null; - } - if (ref.startsWith("neighbor.")) { - return ctx.neighbor?.[ref.slice("neighbor.".length)] ?? null; - } - if (ref.startsWith("edge.")) { - return ctx.edge?.[ref.slice("edge.".length)] ?? null; - } - return null; -} - -function compareValues( - a: unknown, - b: unknown, - direction: "asc" | "desc", -): number { - const av = a ?? ""; - const bv = b ?? ""; - let cmp: number; - if (typeof av === "number" && typeof bv === "number") cmp = av - bv; - else cmp = String(av).localeCompare(String(bv)); - return direction === "asc" ? cmp : -cmp; -} - -function inferColumns(rows: FixtureNode[]): string[] { - const set = new Set(); - for (const row of rows) { - for (const key of Object.keys(row)) set.add(key); - } - return [...set]; -} diff --git a/packages/fixture/src/source.test.ts b/packages/fixture/src/source.test.ts deleted file mode 100644 index 598f7c9..0000000 --- a/packages/fixture/src/source.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { FixtureSource } from "./source.js"; -import type { Fixture } from "./validator.js"; - -const FIXTURE: Fixture = { - version: 1, - title: "tiny", - nodes: [ - { type: "PolicyClause", id: "c1", title: "Clause", status: "draft" }, - ], - edges: [], -}; - -describe("FixtureSource", () => { - it("declares runtime capabilities", () => { - const source = new FixtureSource(FIXTURE); - expect(source.capabilities()).toMatchObject({ - structuredQueryKinds: ["nodes", "path", "ego"], - rawGq: false, - mutationKinds: ["set_field"], - branchReads: false, - snapshotReads: false, - branchWrites: false, - }); - }); - - it("implements the runtime read and mutation contract", async () => { - const source = new FixtureSource(FIXTURE); - const before = await source.read( - { - cellId: "clauses", - fixtureQuery: { - kind: "nodes", - where: { type: "PolicyClause" }, - project: ["id", "status"], - }, - }, - { cellId: "clauses", readTarget: {}, state: {} }, - ); - expect(before.rows[0]?.status).toBe("draft"); - await source.mutate( - { - params: { - kind: "set_field", - target_type: "PolicyClause", - target_id: "c1", - field: "status", - value: "approved", - }, - }, - { readTarget: {}, writeTarget: {}, state: {} }, - ); - const after = await source.read( - { - cellId: "clauses", - fixtureQuery: { - kind: "nodes", - where: { type: "PolicyClause" }, - project: ["id", "status"], - }, - }, - { cellId: "clauses", readTarget: {}, state: {} }, - ); - expect(after.rows[0]?.status).toBe("approved"); - }); -}); diff --git a/packages/fixture/src/source.ts b/packages/fixture/src/source.ts deleted file mode 100644 index ff8c831..0000000 --- a/packages/fixture/src/source.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { MutationResult } from "@modernrelay/notebook-core"; -import type { - ExecutionContext, - MutationCommand, - MutationContext, - ReadOutput, - ReadRequest, - Source, - SourceCapabilities, -} from "@modernrelay/notebook-core"; -import type { Fixture } from "./validator.js"; -import { runFixtureQuery } from "./runner.js"; - -export type FixtureReadInput = ReadRequest; -export type FixtureReadOutput = ReadOutput; - -/** - * In-memory source. Mutations update the underlying fixture object in - * place — subsequent `read()` calls see the new state. The current - * fixture mutates **per-process**: changes do not persist across TUI - * restarts. (Future: optional writeback to disk.) - */ -export class FixtureSource implements Source { - constructor(private readonly fixture: Fixture) {} - - capabilities(): SourceCapabilities { - return { - structuredQueryKinds: ["nodes", "path", "ego"], - rawGq: false, - mutationKinds: ["set_field"], - branchReads: false, - snapshotReads: false, - branchWrites: false, - }; - } - - async read( - input: FixtureReadInput, - _context: ExecutionContext, - ): Promise { - if (!input.fixtureQuery) { - throw new Error( - "FixtureSource.read called without `fixture_query`; the notebook " + - "may be mixing server-mode cells (`query.source`) with a fixture-mode " + - "notebook header. Set `query.fixture: { kind: ... }` on each cell.", - ); - } - const { columns, rows } = runFixtureQuery(input.fixtureQuery, this.fixture); - return { - query_name: input.queryName ?? input.cellId ?? "fixture", - target: "fixture", - row_count: rows.length, - columns, - rows, - }; - } - - async mutate( - command: MutationCommand, - _context: MutationContext, - ): Promise { - const params = command.params; - switch (params.kind) { - case "set_field": { - const node = this.fixture.nodes.find((n) => n.id === params.target_id); - if (!node) { - throw new Error( - `mutate set_field: no node with id '${params.target_id}'`, - ); - } - if (node.type !== params.target_type) { - throw new Error( - `mutate set_field: type mismatch — expected ${params.target_type}, found ${node.type} for id '${params.target_id}'`, - ); - } - // In-place mutation; the App's epoch bump after this call triggers - // a re-execution that re-reads the (now-updated) fixture. - node[params.field] = params.value; - return { kind: "ok" }; - } - } - } -} diff --git a/packages/fixture/src/validator.ts b/packages/fixture/src/validator.ts deleted file mode 100644 index 8116e98..0000000 --- a/packages/fixture/src/validator.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from "zod"; - -// `.loose()` is the Zod 4 form of `.passthrough()` — keeps extra fields on -// nodes and edges (the call sites use them for typed projections). -export const FixtureNodeSchema = z - .object({ - type: z.string().min(1), - id: z.string().min(1), - }) - .loose(); -export type FixtureNode = z.infer; - -export const FixtureEdgeSchema = z - .object({ - type: z.string().min(1), - from: z.string().min(1), - to: z.string().min(1), - }) - .loose(); -export type FixtureEdge = z.infer; - -export const FixtureSchema = z.object({ - version: z.literal(1), - title: z.string().min(1), - nodes: z.array(FixtureNodeSchema), - edges: z.array(FixtureEdgeSchema), -}); -export type Fixture = z.infer; - -/** - * Validate an already-parsed object as a Fixture. Browser-safe (no fs). - * Throws if any edge endpoint does not resolve to a known node id, or - * if any node id is duplicated. - */ -export function parseFixture(raw: unknown, label = "fixture"): Fixture { - const fixture = FixtureSchema.parse(raw); - - const ids = new Set(); - const dupes = new Set(); - for (const node of fixture.nodes) { - if (ids.has(node.id)) dupes.add(node.id); - ids.add(node.id); - } - if (dupes.size > 0) { - throw new Error( - `${label}: duplicate node id(s): ${[...dupes].join(", ")}`, - ); - } - - const orphans: string[] = []; - for (const edge of fixture.edges) { - if (!ids.has(edge.from)) orphans.push(`${edge.type} from=${edge.from}`); - if (!ids.has(edge.to)) orphans.push(`${edge.type} to=${edge.to}`); - } - if (orphans.length > 0) { - throw new Error( - `${label}: ${orphans.length} edge endpoint(s) reference unknown nodes: ${orphans - .slice(0, 5) - .join("; ")}${orphans.length > 5 ? "; …" : ""}`, - ); - } - - return fixture; -} diff --git a/packages/fixture/tsconfig.json b/packages/fixture/tsconfig.json deleted file mode 100644 index dc1447c..0000000 --- a/packages/fixture/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src/**/*"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] -} diff --git a/packages/tui/package.json b/packages/tui/package.json index e43a7d9..6babf4c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -20,7 +20,6 @@ "@json-render/ink": "^0.19.0", "@modernrelay/notebook-core": "workspace:*", "@modernrelay/notebook-client": "workspace:*", - "@modernrelay/notebook-fixture": "workspace:*", "ink": "^6.0.0", "react": "^19.2.6", "yaml": "^2.8.4" diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index cd2ca99..a22b672 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -1,6 +1,6 @@ import React from "react"; import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { resolve } from "node:path"; // When stdin isn't a TTY (CI, piped runs, smoke tests), Ink + @json-render/ink // throw inside `useInput` because raw mode isn't available. We stub the few @@ -24,8 +24,6 @@ if (RAN_NON_TTY) { import { render } from "ink"; import { parseNotebook } from "@modernrelay/notebook-core"; -import { FixtureSource } from "@modernrelay/notebook-fixture"; -import { loadFixture } from "@modernrelay/notebook-fixture/node"; import { Client, ServerSource } from "@modernrelay/notebook-client"; import type { Source } from "@modernrelay/notebook-core"; import { App } from "./App.js"; @@ -68,14 +66,11 @@ function printUsage(): void { process.stderr.write(` omnigraph-tui [--server URL] [--token TOKEN] [--branch NAME] [--graph ID] - Fixture mode — when the notebook declares \`fixture: \`, - reads + writes go to the in-memory FixtureSource. - Server mode — when the notebook declares \`server: \` (or you pass - --server), reads + writes go to omnigraph-server. Bearer - token from --token or \$OMNIGRAPH_TOKEN. omnigraph-server - 0.7.0+ is cluster-only, so a graph id is required: set - \`graph:\` in the notebook, pass --graph, or set - \$OMNIGRAPH_GRAPH_ID. + Reads + writes go to omnigraph-server (via the @modernrelay/omnigraph SDK) + when the notebook declares \`server: \` (or you pass --server). Bearer + token from --token or \$OMNIGRAPH_TOKEN. omnigraph-server 0.7.0+ is + cluster-only, so a graph id is required: set \`graph:\` in the notebook, + pass --graph, or set \$OMNIGRAPH_GRAPH_ID. `); } @@ -104,12 +99,7 @@ export function main(argv: readonly string[]): void { let source: Source; let label: string; - if (notebook.fixture) { - const fixturePath = resolve(dirname(notebookAbs), notebook.fixture); - const fixture = loadFixture(fixturePath); - source = new FixtureSource(fixture); - label = `fixture: ${notebook.fixture}`; - } else if (serverUrl) { + if (serverUrl) { if (!graphId) { process.stderr.write( `omnigraph-tui: server mode requires a graph id (omnigraph-server 0.7.0+\n` + @@ -129,8 +119,7 @@ export function main(argv: readonly string[]): void { label = `server: ${serverUrl} · graph: ${graphId}`; } else { process.stderr.write( - `omnigraph-tui: notebook has neither \`fixture:\` nor \`server:\`,\n` + - `and no --server flag was given. Set one of the three.\n`, + `omnigraph-tui: notebook has no \`server:\` and no --server flag was given.\n`, ); process.exit(2); } diff --git a/packages/web/package.json b/packages/web/package.json index 6a1a7a2..67566da 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,7 +19,6 @@ "@json-render/react": "^0.19.0", "@modernrelay/notebook-core": "workspace:*", "@modernrelay/notebook-client": "workspace:*", - "@modernrelay/notebook-fixture": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.20.0", diff --git a/packages/web/src/App.test.ts b/packages/web/src/App.test.ts index 103ae4f..8a08cf6 100644 --- a/packages/web/src/App.test.ts +++ b/packages/web/src/App.test.ts @@ -6,34 +6,26 @@ describe("buildConfig", () => { vi.unstubAllGlobals(); }); - it("uses bundled fixture mode when requested", async () => { - stubWindow("http://127.0.0.1:5173/?mode=fixture"); - const config = await buildConfig(); - expect(config.mode).toBe("fixture"); - expect(config.label).toContain("fixture:"); - expect(config.source.capabilities().rawGq).toBe(false); - }); - it("uses server URL, token, branch, and graph from query params", async () => { const storage = stubWindow( - "http://127.0.0.1:5173/?mode=server&server=http://example.test&token=tok&branch=review&graph=acme", + "http://127.0.0.1:5173/?server=http://example.test&token=tok&branch=review&graph=acme", ); const config = await buildConfig(); - expect(config.mode).toBe("server"); expect(config.label).toBe("server: http://example.test · graph: acme · review"); expect(config.source.capabilities().rawGq).toBe(true); expect(storage.get("omnigraph_token")).toBe("tok"); }); - it("loads notebook URL and resolves fixture relative to it", async () => { - stubWindow("http://127.0.0.1:5173/?notebook=/dash/notebook.yaml"); + it("loads a notebook from the ?notebook= URL", async () => { + stubWindow("http://127.0.0.1:5173/?notebook=/dash/notebook.yaml&graph=acme"); const fetch = vi.fn(async (input: URL | string) => { const url = String(input); if (url === "http://127.0.0.1:5173/dash/notebook.yaml") { return response(` version: 1 title: Remote -fixture: ./fixture.json +server: http://example.test +graph: acme cells: - id: rows lens: Table @@ -42,24 +34,15 @@ cells: props: { columns: [{ key: id, label: ID }] } `); } - if (url === "http://127.0.0.1:5173/dash/fixture.json") { - return response(JSON.stringify({ - version: 1, - title: "Remote Fixture", - nodes: [{ type: "Decision", id: "d1" }], - edges: [], - })); - } throw new Error(`unexpected fetch ${url}`); }); vi.stubGlobal("fetch", fetch); const config = await buildConfig(); - expect(config.mode).toBe("fixture"); expect(config.notebook.title).toBe("Remote"); + expect(config.label).toContain("graph: acme"); expect(fetch.mock.calls.map((call) => String(call[0]))).toEqual([ "http://127.0.0.1:5173/dash/notebook.yaml", - "http://127.0.0.1:5173/dash/fixture.json", ]); }); }); @@ -67,7 +50,7 @@ cells: function stubWindow(href: string): Map { const storage = new Map(); vi.stubGlobal("window", { - location: { href }, + location: { href, origin: new URL(href).origin }, localStorage: { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 5c5bdca..19234c9 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -216,7 +216,7 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { - {config.mode} + server diff --git a/packages/web/src/config.ts b/packages/web/src/config.ts index 2182521..66098da 100644 --- a/packages/web/src/config.ts +++ b/packages/web/src/config.ts @@ -1,17 +1,13 @@ import { parseNotebook } from "@modernrelay/notebook-core"; import { Client, ServerSource } from "@modernrelay/notebook-client"; import type { Source } from "@modernrelay/notebook-core"; -import { FixtureSource, parseFixture } from "@modernrelay/notebook-fixture"; import defaultServerNotebookYaml from "../../../examples/company-server.notebook.yaml?raw"; -import defaultFixtureNotebookYaml from "../../../examples/company.notebook.yaml?raw"; -import defaultFixtureJson from "../../../examples/fixtures/company-context.json?raw"; export interface AppConfig { notebook: ReturnType; source: Source; label: string; - mode: "server" | "fixture"; } function readToken(): string | undefined { @@ -26,11 +22,6 @@ function readToken(): string | undefined { export async function buildConfig(): Promise { const url = new URL(window.location.href); - const requestedMode = url.searchParams.get("mode"); - const mode = - requestedMode === "fixture" || requestedMode === "server" - ? requestedMode - : undefined; const notebookParam = url.searchParams.get("notebook"); const notebookUrl = notebookParam !== null ? new URL(notebookParam, window.location.href) : null; @@ -38,29 +29,8 @@ export async function buildConfig(): Promise { const notebookYaml = notebookUrl !== null ? await fetchText(notebookUrl) - : mode === "fixture" - ? defaultFixtureNotebookYaml - : defaultServerNotebookYaml; + : defaultServerNotebookYaml; const notebook = parseNotebook(notebookYaml); - const resolvedMode: "server" | "fixture" = - mode ?? (notebook.fixture ? "fixture" : "server"); - - if (resolvedMode === "fixture") { - if (!notebook.fixture) { - throw new Error("Fixture mode requires top-level `fixture:` in notebook."); - } - const rawFixture = - notebookUrl === null - ? defaultFixtureJson - : await fetchText(new URL(notebook.fixture, notebookUrl)); - const fixture = parseFixture(JSON.parse(rawFixture), notebook.fixture); - return { - notebook, - source: new FixtureSource(fixture), - label: `fixture: ${notebook.fixture}`, - mode: "fixture", - }; - } const serverParam = url.searchParams.get("server") ?? notebook.server; // A relative server (e.g. `?server=/og`, the dev-proxy same-origin path) @@ -92,7 +62,6 @@ export async function buildConfig(): Promise { notebook, source: new ServerSource(client, branch ? { branch } : {}), label: `server: ${server} · graph: ${graph}${branch ? ` · ${branch}` : ""}`, - mode: "server", }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 702e725..5c04d0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,9 +45,6 @@ importers: '@modernrelay/notebook-core': specifier: workspace:* version: link:../core - '@modernrelay/notebook-fixture': - specifier: workspace:* - version: link:../fixture '@modernrelay/notebook-tui': specifier: workspace:* version: link:../tui @@ -90,19 +87,6 @@ importers: specifier: ^4.1.5 version: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) - packages/fixture: - dependencies: - '@modernrelay/notebook-core': - specifier: workspace:* - version: link:../core - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - vitest: - specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.2)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) - packages/tui: dependencies: '@json-render/core': @@ -117,9 +101,6 @@ importers: '@modernrelay/notebook-core': specifier: workspace:* version: link:../core - '@modernrelay/notebook-fixture': - specifier: workspace:* - version: link:../fixture ink: specifier: ^6.0.0 version: 6.8.0(@types/react@19.2.14)(react@19.2.6) @@ -163,9 +144,6 @@ importers: '@modernrelay/notebook-core': specifier: workspace:* version: link:../core - '@modernrelay/notebook-fixture': - specifier: workspace:* - version: link:../fixture class-variance-authority: specifier: ^0.7.1 version: 0.7.1 From 5f94d1366cc7e80992ee8a2aa59b1d9f0e40f9e5 Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 22 Jun 2026 16:26:31 +0300 Subject: [PATCH 2/9] Query model: cells reference catalog queries by ref (Phase 1A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move reads off the client-side query compiler and onto server-owned catalog queries invoked by name — the consistency-over-canon model from dash-books-canon.md §4.1. - spec: `query.ref` (catalog query, the default) + `query.rawGq` (gated raw-.gq escape hatch); delete the nodes/path/ego DSL + `query.fixture`; make Notebook/Cell/Query schemas `.strict()` (reject stale keys — internal tool, no version negotiation) - runtime: ReadRequest.queryRef; SourceCapabilities.namedQueries (drops structuredQueryKinds); compatibility checks ref→namedQueries, rawGq→rawGq; delete resolveFixtureQuery/resolveWhere - client: Client.invoke wraps og.queries.invoke; ServerSource routes ref→invoke / rawGq→query; delete translate.ts read translators + the ego decomposition planner (keep translateMutation for interim set_field) - demo: author examples/server/queries/*.gq (3) + register via `queries: ./queries/` in server-demo's cluster; migrate company-server.notebook.yaml cells to `query.ref` - tests rewritten for the ref/rawGq model + strict schema Read-only (Phase 1); writes stay on the interim set_field path. Verified: pnpm -r build/typecheck/test green (49 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/company-server.notebook.yaml | 70 +--- .../server/queries/decisions-by-urgency.gq | 15 + examples/server/queries/policy-clauses.gq | 17 + examples/server/queries/signal-to-decision.gq | 12 + packages/client/src/http.ts | 36 ++ packages/client/src/index.ts | 4 - packages/client/src/source.test.ts | 184 ++-------- packages/client/src/source.ts | 344 ++---------------- packages/client/src/translate.test.ts | 167 +-------- packages/client/src/translate.ts | 283 +------------- packages/core/src/runtime/compatibility.ts | 17 +- packages/core/src/runtime/index.test.ts | 113 +++--- packages/core/src/runtime/resolve.ts | 42 +-- packages/core/src/runtime/runtime.ts | 10 +- packages/core/src/runtime/types.ts | 11 +- packages/core/src/spec/index.test.ts | 86 ++--- packages/core/src/spec/index.ts | 162 +++------ packages/web/src/App.test.ts | 3 +- scripts/server-demo.sh | 3 + 19 files changed, 308 insertions(+), 1271 deletions(-) create mode 100644 examples/server/queries/decisions-by-urgency.gq create mode 100644 examples/server/queries/policy-clauses.gq create mode 100644 examples/server/queries/signal-to-decision.gq diff --git a/examples/company-server.notebook.yaml b/examples/company-server.notebook.yaml index cc008b8..b6ce39e 100644 --- a/examples/company-server.notebook.yaml +++ b/examples/company-server.notebook.yaml @@ -2,40 +2,22 @@ version: 1 title: Company context (live cluster-backed omnigraph) server: http://127.0.0.1:8080 # omnigraph-server 0.7.0+ is cluster-only: reads/writes are served under -# /graphs/{graph}/…. `--graph`/`?graph=`/$OMNIGRAPH_GRAPH_ID override this. +# /graphs/{graph}/…. `--graph`/`?graph=` override this. graph: company -# Server-mode demo. Same lens components, same action wiring as the fixture -# notebook — but reads go to omnigraph-server (POST /graphs/{graph}/query) and -# ActionList clicks go to POST /graphs/{graph}/mutate (one atomic Lance commit -# per click). Mutations persist across server restarts. Run scripts/server-demo.sh -# first to boot a local filesystem cluster serving this `graph`. -# -# Translation scope: only `nodes` and `path` query kinds translate to .gq. -# Per-step `where` filters are not yet translated, so this notebook shows ALL -# clauses rather than narrowing by selected policy. The ActionList per-row -# buttons still let the user approve/reject any individual clause. +# Server-mode demo. Each cell binds to a server-owned catalog query by name +# (`query.ref` → POST /graphs/{graph}/queries/{ref}); the query bodies live in +# examples/server/queries/*.gq, registered in the cluster. ActionList clicks +# still go to POST /graphs/{graph}/mutate (one atomic Lance commit per click — +# the interim set_field write path). Run scripts/server-demo.sh first to boot a +# local filesystem cluster serving this `graph` with these queries. cells: - id: policy-clause-review lens: ActionList - query: - fixture: - kind: path - steps: - - { var: p, type: Policy } - - { edge: HasClause, var: c, type: PolicyClause } - project: - # Project `slug` (server's @key field) as `id` for the ActionList, - # which references rows by their `id_column`. The mutation handler - # passes the exposed id back to the server as `target_id`, where - # the translator emits `where slug = $target_id`. - - { var: c.slug, as: id } - - { var: c.title, as: title } - - { var: c.text, as: body } - - { var: c.status, as: status } - - { var: p.slug, as: policy_id } + # → examples/server/queries/policy-clauses.gq + query: { ref: policy_clauses_for_review } props: id_column: id title_column: title @@ -60,24 +42,10 @@ cells: - id: decisions-by-urgency lens: Table - # Inline filter — operates on this cell's data view, not its own screen. - # The Select binds /filters/decision_status; the where clause picks - # it up via $state and the executor pre-resolves before the .gq - # translator emits the parameterized match. - controls: - - lens: Select - props: - label: Status filter - options: ["", proposed, accepted, rejected, superseded] - value: { $bindState: "/filters/decision_status" } - query: - fixture: - kind: nodes - where: - type: Decision - status: { $state: "/filters/decision_status" } - project: [slug, title, status, urgency] - order_by: { field: urgency, direction: asc } + # → examples/server/queries/decisions-by-urgency.gq (all decisions, ordered). + # Status-filtered variants arrive with the richer catalog-query work + # (dash-books-canon.md §4.3 / Phase 2). + query: { ref: decisions_by_urgency } props: columns: - { key: slug, label: ID } @@ -87,16 +55,8 @@ cells: - id: signal-to-decision lens: Path - query: - fixture: - kind: path - steps: - - { var: s, type: Signal } - - { edge: Triggers, var: d, type: Decision } - project: - - { var: s.title, as: signal_title } - - { literal: triggers, as: triggers_label } - - { var: d.title, as: decision_title } + # → examples/server/queries/signal-to-decision.gq + query: { ref: signal_to_decision } props: steps: - { from_column: signal_title, predicate_column: triggers_label, to_column: decision_title } diff --git a/examples/server/queries/decisions-by-urgency.gq b/examples/server/queries/decisions-by-urgency.gq new file mode 100644 index 0000000..10ffa24 --- /dev/null +++ b/examples/server/queries/decisions-by-urgency.gq @@ -0,0 +1,15 @@ +// All decisions, ordered by urgency. Backs the `decisions-by-urgency` Table. +// v1 is param-free (returns every decision); status-filtered variants arrive +// with the richer catalog-query work (dash-books-canon.md §4.3 / Phase 2). +query decisions_by_urgency() { + match { + $d: Decision + } + return { + $d.slug as slug, + $d.title as title, + $d.status as status, + $d.urgency as urgency + } + order { $d.urgency asc } +} diff --git a/examples/server/queries/policy-clauses.gq b/examples/server/queries/policy-clauses.gq new file mode 100644 index 0000000..e1c5e4b --- /dev/null +++ b/examples/server/queries/policy-clauses.gq @@ -0,0 +1,17 @@ +// Policy clauses for review — every clause with its parent policy. +// Backs the `policy-clause-review` ActionList cell. `slug` is the @key +// field; projected as `id` so the lens references rows by it (and the +// mutation sends it back as `target_id`). +query policy_clauses_for_review() { + match { + $p: Policy + $p hasClause $c + } + return { + $c.slug as id, + $c.title as title, + $c.text as body, + $c.status as status, + $p.slug as policy_id + } +} diff --git a/examples/server/queries/signal-to-decision.gq b/examples/server/queries/signal-to-decision.gq new file mode 100644 index 0000000..5d832a2 --- /dev/null +++ b/examples/server/queries/signal-to-decision.gq @@ -0,0 +1,12 @@ +// Signal → Decision triggers, for the `signal-to-decision` Path lens. +query signal_to_decision() { + match { + $s: Signal + $s triggers $d + } + return { + $s.title as signal_title, + "triggers" as triggers_label, + $d.title as decision_title + } +} diff --git a/packages/client/src/http.ts b/packages/client/src/http.ts index 74436fe..8aff707 100644 --- a/packages/client/src/http.ts +++ b/packages/client/src/http.ts @@ -16,6 +16,7 @@ import { OmnigraphError, type QueryInput as SdkQueryInput, type MutationInput as SdkMutationInput, + type Read as SdkRead, } from "@modernrelay/omnigraph"; export interface ClientOptions { @@ -136,6 +137,41 @@ export class Client { } } + /** + * Invoke a server-owned catalog query by name (`POST /queries/{name}`). The + * query body lives in the cluster registry, not here — we pass only runtime + * inputs. `expectMutation: false` asserts a read (the server rejects a stored + * mutation), so the untagged `Read | Change` response is a read envelope. + */ + async invoke( + name: string, + input: { params?: Record; branch?: string; snapshot?: string }, + signal?: AbortSignal, + ): Promise { + this.requireGraph(`/queries/${name}`); + try { + const r = (await this.og.queries.invoke( + name, + { + expectMutation: false, + ...(input.params !== undefined ? { params: input.params } : {}), + ...(input.branch !== undefined ? { branch: input.branch } : {}), + ...(input.snapshot !== undefined ? { snapshot: input.snapshot } : {}), + }, + signal ? { signal } : {}, + )) as SdkRead; + return { + query_name: r.queryName, + target: r.target?.branch ?? r.target?.snapshot ?? "main", + row_count: r.rowCount, + columns: r.columns ?? [], + rows: (r.rows ?? []) as Record[], + }; + } catch (e) { + throw toHttpError(e, `/queries/${name}`); + } + } + async mutate(body: MutateInput, signal?: AbortSignal): Promise { this.requireGraph("/mutate"); try { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 28ad02e..de5a626 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -10,11 +10,7 @@ export { } from "./http.js"; export { - translateFixtureQuery, - translateNodesQuery, - translatePathQuery, translateMutation, - edgeToPredicate, UnsupportedTranslationError, type TranslatedQuery, } from "./translate.js"; diff --git a/packages/client/src/source.test.ts b/packages/client/src/source.test.ts index 04ed17a..7ca8941 100644 --- a/packages/client/src/source.test.ts +++ b/packages/client/src/source.test.ts @@ -13,7 +13,7 @@ describe("ServerSource", () => { it("declares runtime capabilities", () => { const source = new ServerSource(fakeClient({})); expect(source.capabilities()).toMatchObject({ - structuredQueryKinds: ["nodes", "path", "ego"], + namedQueries: true, rawGq: true, mutationKinds: ["set_field"], branchReads: true, @@ -22,181 +22,46 @@ describe("ServerSource", () => { }); }); - it("passes raw .gq through as the deprecated escape hatch", async () => { - const query = vi.fn(async () => readOutput([])); - const source = new ServerSource(fakeClient({ query })); - await source.read( - { - cellId: "raw", - querySource: "query q() { match { $d: Decision } return { $d.slug as slug } }", - queryName: "q", - }, - CTX, - ); - expect(query.mock.calls[0]?.[0]).toMatchObject({ - query: expect.stringContaining("query q"), - name: "q", - }); - }); - - it("decomposes ego reads and synthesizes bare-center rows", async () => { - const query = vi.fn(async (input: QueryInput) => { - if (input.name === "decision_neighbors_center") { - return readOutput([{ id: "d1", name: "D1", __ng_center_id: "d1" }]); - } - return readOutput([]); - }); - const source = new ServerSource(fakeClient({ query })); + it("invokes a catalog query by ref with params + target", async () => { + const invoke = vi.fn(async () => readOutput([{ id: "d1" }])); + const source = new ServerSource(fakeClient({ invoke }), { branch: "main" }); const out = await source.read( { - cellId: "decision-neighbors", - fixtureQuery: { - kind: "ego", - center: { type: "Decision", where: { slug: "d1" } }, - out: ["GovernedBy"], - in: [], - project: [ - { var: "center.slug", as: "id" }, - { var: "center.title", as: "name" }, - { var: "edge_type", as: "predicate" }, - { var: "neighbor.slug", as: "neighbor" }, - ], - }, + cellId: "decisions", + queryRef: "decisions_by_urgency", + params: { status: "open" }, }, CTX, ); - expect(query).toHaveBeenCalledTimes(2); - expect(out.columns).toEqual(["id", "name", "predicate", "neighbor"]); - expect(out.rows).toEqual([ - { id: "d1", name: "D1", predicate: null, neighbor: null }, - ]); - }); - - it("merges out and in ego incident rows across multiple edge types", async () => { - const query = vi.fn(async (input: QueryInput) => { - if (input.name === "decision_neighbors_center") { - return readOutput([ - { id: "d1", name: "D1", __ng_center_id: "d1" }, - { id: "d2", name: "D2", __ng_center_id: "d2" }, - ]); - } - if (input.name === "decision_neighbors_out_GovernedBy") { - return readOutput([ - { - id: "d1", - name: "D1", - predicate: "GovernedBy", - direction: "out", - neighbor: "policy-1", - __ng_center_id: "d1", - }, - ]); - } - if (input.name === "decision_neighbors_in_Owns") { - return readOutput([ - { - id: "d1", - name: "D1", - predicate: "Owns", - direction: "in", - neighbor: "andrew", - __ng_center_id: "d1", - }, - ]); - } - return readOutput([]); + expect(invoke.mock.calls[0]?.[0]).toBe("decisions_by_urgency"); + expect(invoke.mock.calls[0]?.[1]).toMatchObject({ + params: { status: "open" }, + branch: "main", }); - const source = new ServerSource(fakeClient({ query })); - const out = await source.read( - { - cellId: "decision-neighbors", - fixtureQuery: { - kind: "ego", - center: { type: "Decision", where: {} }, - out: ["GovernedBy"], - in: ["Owns"], - project: [ - { var: "center.slug", as: "id" }, - { var: "center.title", as: "name" }, - { var: "edge_type", as: "predicate" }, - { var: "edge_direction", as: "direction" }, - { var: "neighbor.slug", as: "neighbor" }, - ], - }, - }, - CTX, - ); - - expect(query).toHaveBeenCalledTimes(3); - expect(out.rows).toEqual([ - { - id: "d1", - name: "D1", - predicate: "GovernedBy", - direction: "out", - neighbor: "policy-1", - }, - { - id: "d1", - name: "D1", - predicate: "Owns", - direction: "in", - neighbor: "andrew", - }, - { - id: "d2", - name: "D2", - predicate: null, - direction: null, - neighbor: null, - }, - ]); + expect(out.rows).toEqual([{ id: "d1" }]); }); - it("passes resolved params into generated ego reads", async () => { + it("passes raw .gq through the escape hatch via query", async () => { const query = vi.fn(async () => readOutput([])); const source = new ServerSource(fakeClient({ query })); await source.read( { - cellId: "decision-neighbors", - params: { actor: "andrew" }, - fixtureQuery: { - kind: "ego", - center: { type: "Decision", where: { slug: "d1" } }, - out: ["GovernedBy"], - in: [], - project: [{ var: "center.slug", as: "id" }], - }, + cellId: "raw", + querySource: + "query q() { match { $d: Decision } return { $d.slug as slug } }", + queryName: "q", }, CTX, ); - expect(query.mock.calls[0]?.[0].params).toMatchObject({ - actor: "andrew", - w_slug: "d1", - }); - expect(query.mock.calls[1]?.[0].params).toMatchObject({ - actor: "andrew", - w_slug: "d1", + expect(query.mock.calls[0]?.[0]).toMatchObject({ + query: expect.stringContaining("query q"), + name: "q", }); }); - it("rejects unsupported server ego projections clearly", async () => { + it("throws when a read has neither ref nor rawGq", async () => { const source = new ServerSource(fakeClient({})); - await expect( - source.read( - { - cellId: "bad-ego", - fixtureQuery: { - kind: "ego", - center: { type: "Decision", where: {} }, - out: ["Owns"], - in: [], - project: [{ var: "edge.weight", as: "weight" }], - }, - }, - CTX, - ), - ).rejects.toThrow(/not supported in server mode/); + await expect(source.read({ cellId: "bad" }, CTX)).rejects.toThrow(/neither/); }); it("uses mutation write target branch from runtime context", async () => { @@ -252,6 +117,10 @@ describe("ServerSource", () => { }); function fakeClient(overrides: { + invoke?: ( + name: string, + input: { params?: Record; branch?: string; snapshot?: string }, + ) => Promise>; query?: (input: QueryInput) => Promise>; mutate?: (input: MutateInput) => Promise<{ branch: string; @@ -261,6 +130,7 @@ function fakeClient(overrides: { }>; }): Client { return { + invoke: overrides.invoke ?? (async () => readOutput([])), query: overrides.query ?? (async () => readOutput([])), mutate: overrides.mutate ?? diff --git a/packages/client/src/source.ts b/packages/client/src/source.ts index ff2cc90..41ee2d9 100644 --- a/packages/client/src/source.ts +++ b/packages/client/src/source.ts @@ -1,8 +1,9 @@ /** * `ServerSource` is the runtime Source backed by omnigraph-server. * - * Structured notebook queries are translated into `.gq` reads. Raw `.gq` - * remains available as a deprecated server-only escape hatch. + * Reads are server-owned catalog queries invoked by name (`query.ref` → + * `og.queries.invoke`). Raw `.gq` (`query.rawGq`) remains a capability-gated + * escape hatch sent ad-hoc via `og.query`. No client-side query compilation. */ import type { @@ -14,15 +15,9 @@ import type { Source, SourceCapabilities, } from "@modernrelay/notebook-core"; -import type { FixtureEgoQuery, MutationResult } from "@modernrelay/notebook-core"; +import type { MutationResult } from "@modernrelay/notebook-core"; import { Client, type ChangeOutput } from "./http.js"; -import { - edgeToPredicate, - translateFixtureQuery, - translateMutation, - UnsupportedTranslationError, - type TranslatedQuery, -} from "./translate.js"; +import { translateMutation } from "./translate.js"; export interface ServerSourceOptions { /** Default branch for reads + writes. CLI flag and notebook field win over this. */ @@ -37,7 +32,7 @@ export class ServerSource implements Source { capabilities(): SourceCapabilities { return { - structuredQueryKinds: ["nodes", "path", "ego"], + namedQueries: true, rawGq: true, mutationKinds: ["set_field"], branchReads: true, @@ -50,40 +45,35 @@ export class ServerSource implements Source { input: ReadRequest, context: ExecutionContext, ): Promise { - if (!input.fixtureQuery) { - if (input.querySource === undefined) { - throw new Error( - "ServerSource.read: cell has no fixtureQuery and no querySource", - ); - } + const target = this.targetTriple(input); + + // Canonical path: a server-owned catalog query invoked by name. + if (input.queryRef !== undefined) { + return this.client.invoke( + input.queryRef, + { + ...(input.params !== undefined && { params: input.params }), + ...target, + }, + context.signal, + ); + } + + // Escape hatch: raw `.gq` sent ad-hoc. + if (input.querySource !== undefined) { return this.client.query( { query: input.querySource, ...(input.queryName !== undefined && { name: input.queryName }), ...(input.params !== undefined && { params: input.params }), - ...this.targetTriple(input), + ...target, }, context.signal, ); } - if (input.fixtureQuery.kind === "ego") { - return this.readEgo(input.fixtureQuery, input, context); - } - - const translated = translateFixtureQuery( - input.fixtureQuery, - input.cellId ?? "ng", - ); - const params = mergeParams(translated.params, input.params); - return this.client.query( - { - query: translated.query_source, - name: translated.query_name, - params, - ...this.targetTriple(input), - }, - context.signal, + throw new Error( + "ServerSource.read: cell query has neither a catalog `ref` nor raw `rawGq`", ); } @@ -106,69 +96,6 @@ export class ServerSource implements Source { return { kind: "ok" }; } - private async readEgo( - query: FixtureEgoQuery, - input: ReadRequest, - context: ExecutionContext, - ): Promise { - const plan = translateEgoQuery(query, sanitizeQueryName(input.cellId)); - const target = this.targetTriple(input); - // Center + every incident read are mutually independent: each incident - // query re-binds the center via its own where-clause, and the center read - // is only consumed at merge time. So fire them all concurrently rather - // than serially — collapses (k+1) round-trips into ~1. (Same uncapped - // Promise.all fan-out the runtime uses across cells.) - const runRead = (q: TranslatedQuery) => - this.client.query( - { - query: q.query_source, - name: q.query_name, - params: mergeParams(q.params, input.params), - ...target, - }, - context.signal, - ); - - const [center, ...incidentResults] = await Promise.all([ - runRead(plan.center), - ...plan.incident.map((part) => runRead(part.query)), - ]); - - if (query.out.length === 0 && query.in.length === 0) { - return { - query_name: input.queryName ?? plan.name, - target: center.target, - row_count: 0, - columns: query.project.map((projection) => projection.as), - rows: [], - }; - } - - const incidentRows = incidentResults.flatMap((result) => result.rows); - - const incidentCenterIds = new Set( - incidentRows - .map((row) => row[INTERNAL_CENTER_ID]) - .filter((value): value is string => typeof value === "string"), - ); - const finalRows = incidentRows.map(stripInternalColumns); - for (const centerRow of center.rows) { - const centerId = centerRow[INTERNAL_CENTER_ID]; - if (typeof centerId === "string" && incidentCenterIds.has(centerId)) { - continue; - } - finalRows.push(bareEgoRow(query, centerRow)); - } - - return { - query_name: input.queryName ?? plan.name, - target: center.target, - row_count: finalRows.length, - columns: query.project.map((projection) => projection.as), - rows: finalRows, - }; - } - private targetTriple(input: ReadRequest): { branch?: string; snapshot?: string; @@ -179,224 +106,3 @@ export class ServerSource implements Source { return {}; } } - -function mergeParams( - base: Record, - extra: Record | undefined, -): Record { - if (!extra || Object.keys(extra).length === 0) return base; - return { ...base, ...extra }; -} - -const FIELD_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; -const TYPE_PATTERN = /^[A-Z][a-zA-Z0-9_]*$/; -const INTERNAL_CENTER_ID = "__ng_center_id"; - -interface EgoReadPlan { - name: string; - center: TranslatedQuery; - incident: Array<{ direction: "out" | "in"; edge: string; query: TranslatedQuery }>; -} - -function translateEgoQuery(query: FixtureEgoQuery, queryName: string): EgoReadPlan { - if (!TYPE_PATTERN.test(query.center.type)) { - throw new UnsupportedTranslationError( - `translateEgoQuery: invalid center type '${query.center.type}'`, - ); - } - - const name = queryName || "ng_ego"; - const center = translateEgoCenterQuery(query, `${name}_center`); - const incident: EgoReadPlan["incident"] = []; - - for (const edge of query.out) { - incident.push({ - direction: "out", - edge, - query: translateEgoIncidentQuery( - query, - "out", - edge, - sanitizeQueryName(`${name}_out_${edge}`), - ), - }); - } - for (const edge of query.in) { - incident.push({ - direction: "in", - edge, - query: translateEgoIncidentQuery( - query, - "in", - edge, - sanitizeQueryName(`${name}_in_${edge}`), - ), - }); - } - - return { name, center, incident }; -} - -function translateEgoCenterQuery( - query: FixtureEgoQuery, - queryName: string, -): TranslatedQuery { - const params: Record = {}; - const paramDecls: string[] = []; - const centerMatch = centerBinding(query, params, paramDecls); - const returnParts = [ - ...query.project - .filter((projection) => projection.var.startsWith("center.")) - .map((projection) => { - const field = fieldRef(projection.var, "center"); - return `$c.${field} as ${projection.as}`; - }), - `$c.slug as ${INTERNAL_CENTER_ID}`, - ]; - const decls = paramDecls.length > 0 ? `(${paramDecls.join(", ")})` : "()"; - return { - query_name: queryName, - query_source: - `query ${queryName}${decls} {\n` + - `match {\n ${centerMatch}\n}\n` + - `return { ${returnParts.join(", ")} }\n` + - `}\n`, - params, - }; -} - -function translateEgoIncidentQuery( - query: FixtureEgoQuery, - direction: "out" | "in", - edge: string, - queryName: string, -): TranslatedQuery { - const params: Record = {}; - const paramDecls: string[] = []; - const centerMatch = centerBinding(query, params, paramDecls); - const predicate = edgeToPredicate(edge); - if (!FIELD_PATTERN.test(predicate)) { - throw new UnsupportedTranslationError( - `translateEgoQuery: invalid edge name '${edge}'`, - ); - } - const traversal = - direction === "out" ? `$c ${predicate} $n` : `$n ${predicate} $c`; - const returnParts = [ - ...query.project.map((projection) => - egoProjectionExpr(projection.var, projection.as, direction, edge), - ), - `$c.slug as ${INTERNAL_CENTER_ID}`, - ]; - const decls = paramDecls.length > 0 ? `(${paramDecls.join(", ")})` : "()"; - return { - query_name: queryName, - query_source: - `query ${queryName}${decls} {\n` + - `match {\n ${centerMatch}\n ${traversal}\n}\n` + - `return { ${returnParts.join(", ")} }\n` + - `}\n`, - params, - }; -} - -function centerBinding( - query: FixtureEgoQuery, - params: Record, - paramDecls: string[], -): string { - const matches: string[] = []; - for (const [field, value] of Object.entries(query.center.where)) { - if (!FIELD_PATTERN.test(field)) { - throw new UnsupportedTranslationError( - `translateEgoQuery: invalid center field '${field}'`, - ); - } - const param = uniqueParam(`w_${field}`, params, paramDecls, value); - matches.push(`${field}: $${param}`); - } - const props = matches.length > 0 ? ` { ${matches.join(", ")} }` : ""; - return `$c: ${query.center.type}${props}`; -} - -function egoProjectionExpr( - ref: string, - alias: string, - direction: "out" | "in", - edge: string, -): string { - if (!FIELD_PATTERN.test(alias)) { - throw new UnsupportedTranslationError( - `translateEgoQuery: invalid projection alias '${alias}'`, - ); - } - if (ref.startsWith("center.")) return `$c.${fieldRef(ref, "center")} as ${alias}`; - if (ref.startsWith("neighbor.")) return `$n.${fieldRef(ref, "neighbor")} as ${alias}`; - if (ref === "edge_type") return `${JSON.stringify(edge)} as ${alias}`; - if (ref === "edge_direction") return `${JSON.stringify(direction)} as ${alias}`; - if (ref === "neighbor_type" || ref.startsWith("edge.")) { - throw new UnsupportedTranslationError( - `translateEgoQuery: projection '${ref}' is not supported in server mode`, - ); - } - throw new UnsupportedTranslationError( - `translateEgoQuery: invalid projection '${ref}'`, - ); -} - -function fieldRef(ref: string, prefix: "center" | "neighbor"): string { - const field = ref.slice(prefix.length + 1); - if (!FIELD_PATTERN.test(field)) { - throw new UnsupportedTranslationError( - `translateEgoQuery: invalid field reference '${ref}'`, - ); - } - return field; -} - -function uniqueParam( - hint: string, - params: Record, - decls: string[], - value: unknown, -): string { - let name = hint.replace(/[^a-zA-Z0-9_]/g, "_"); - let i = 0; - while (name in params) { - i += 1; - name = `${hint}_${i}`; - } - params[name] = value; - decls.push(`$${name}: String`); - return name; -} - -function sanitizeQueryName(name: string | undefined): string { - if (!name) return "ng_ego"; - let out = name.replace(/[^a-zA-Z0-9_]/g, "_"); - if (!/^[a-zA-Z_]/.test(out)) out = "q_" + out; - return out; -} - -function stripInternalColumns( - row: Record, -): Record { - const out = { ...row }; - delete out[INTERNAL_CENTER_ID]; - return out; -} - -function bareEgoRow( - query: FixtureEgoQuery, - centerRow: Record, -): Record { - const out: Record = {}; - for (const projection of query.project) { - if (projection.var.startsWith("center.")) { - out[projection.as] = centerRow[projection.as] ?? null; - } else { - out[projection.as] = null; - } - } - return out; -} diff --git a/packages/client/src/translate.test.ts b/packages/client/src/translate.test.ts index c42f1d8..dbe8aad 100644 --- a/packages/client/src/translate.test.ts +++ b/packages/client/src/translate.test.ts @@ -1,132 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - edgeToPredicate, - translateFixtureQuery, - translateMutation, - translateNodesQuery, - translatePathQuery, - UnsupportedTranslationError, -} from "./translate.js"; - -describe("edgeToPredicate", () => { - it("PascalCase → camelCase", () => { - expect(edgeToPredicate("HasClause")).toBe("hasClause"); - expect(edgeToPredicate("Owns")).toBe("owns"); - expect(edgeToPredicate("OwnsPolicy")).toBe("ownsPolicy"); - }); - it("snake_case → camelCase", () => { - expect(edgeToPredicate("has_clause")).toBe("hasClause"); - expect(edgeToPredicate("governed_by")).toBe("governedBy"); - }); - it("already camel", () => { - expect(edgeToPredicate("hasClause")).toBe("hasClause"); - }); -}); - -describe("translateNodesQuery", () => { - it("basic match + return", () => { - const r = translateNodesQuery({ - kind: "nodes", - where: { type: "Decision" }, - project: ["id", "title"], - }); - expect(r.query_source).toContain("$n: Decision"); - expect(r.query_source).toContain("$n.id as id, $n.title as title"); - expect(r.params).toEqual({}); - }); - - it("filters become typed parameters", () => { - const r = translateNodesQuery({ - kind: "nodes", - where: { type: "Decision", status: "proposed" }, - project: ["id"], - }); - expect(r.query_source).toContain("$n.status = $w_status"); - expect(r.query_source).toMatch(/\$w_status: String/); - expect(r.params).toEqual({ w_status: "proposed" }); - }); - - it("emits order_by + limit", () => { - const r = translateNodesQuery({ - kind: "nodes", - where: { type: "Decision" }, - project: ["id"], - order_by: { field: "urgency", direction: "asc" }, - limit: 5, - }); - expect(r.query_source).toContain("order { $n.urgency asc }"); - expect(r.query_source).toContain("limit 5"); - }); - - it("requires where.type", () => { - expect(() => - translateNodesQuery({ kind: "nodes", project: ["id"] }), - ).toThrow(UnsupportedTranslationError); - }); - - it("rejects suspicious field names", () => { - expect(() => - translateNodesQuery({ - kind: "nodes", - where: { type: "Decision", "bad name": "x" }, - project: ["id"], - }), - ).toThrow(); - }); -}); - -describe("translatePathQuery", () => { - it("forward two-step traversal", () => { - const r = translatePathQuery({ - kind: "path", - steps: [ - { var: "p", type: "Policy" }, - { edge: "HasClause", var: "c", type: "PolicyClause" }, - ], - project: [ - { var: "c.id", as: "id" }, - { var: "c.title", as: "title" }, - ], - }); - expect(r.query_source).toContain("$p: Policy"); - expect(r.query_source).toContain("$c: PolicyClause"); - expect(r.query_source).toContain("$p hasClause $c"); - expect(r.query_source).toContain("$c.id as id, $c.title as title"); - }); - - it("reverse traversal flips source/target", () => { - const r = translatePathQuery({ - kind: "path", - steps: [ - { var: "d", type: "Decision" }, - { edge: "Owns", var: "a", type: "Actor", direction: "in" }, - ], - project: [ - { var: "d.title", as: "decision" }, - { var: "a.name", as: "actor" }, - ], - }); - // `Owns` is Actor->Decision; reverse means anchor on decision and walk back to actor. - expect(r.query_source).toContain("$a owns $d"); - }); - - it("literal projections become parameters", () => { - const r = translatePathQuery({ - kind: "path", - steps: [ - { var: "s", type: "Signal" }, - { edge: "Triggers", var: "d", type: "Decision" }, - ], - project: [ - { var: "s.title", as: "signal_title" }, - { literal: "triggered", as: "p" }, - { var: "d.title", as: "decision_title" }, - ], - }); - expect(r.query_source).toMatch(/\$lit_p as p/); - expect(r.params["lit_p"]).toBe("triggered"); - }); -}); +import { translateMutation, UnsupportedTranslationError } from "./translate.js"; describe("translateMutation", () => { it("set_field becomes a parameterized update", () => { @@ -144,38 +17,16 @@ describe("translateMutation", () => { expect(r.query_source).toContain("$target_id: String"); expect(r.params).toEqual({ value: "approved", target_id: "pdr-c1" }); }); -}); - -describe("translateFixtureQuery dispatch", () => { - it("dispatches nodes / path", () => { - expect( - translateFixtureQuery({ - kind: "nodes", - where: { type: "Decision" }, - project: ["id"], - }).query_source, - ).toContain("Decision"); - expect( - translateFixtureQuery({ - kind: "path", - steps: [ - { var: "a", type: "A" }, - { edge: "EdgeName", var: "b", type: "B" }, - ], - project: [{ var: "a.id", as: "id" }], - }).query_source, - ).toContain("edgeName"); - }); - it("rejects ego with a clear v0.8 message", () => { + it("rejects a suspicious field name", () => { expect(() => - translateFixtureQuery({ - kind: "ego", - center: { type: "Policy", where: {} }, - out: ["HasClause"], - in: [], - project: [{ var: "neighbor.id", as: "id" }], + translateMutation({ + kind: "set_field", + target_type: "PolicyClause", + field: "bad field", + value: "x", + target_id: "c1", }), - ).toThrow(/ego.*not supported.*v0\.7/i); + ).toThrow(UnsupportedTranslationError); }); }); diff --git a/packages/client/src/translate.ts b/packages/client/src/translate.ts index 4e193b5..0daba2c 100644 --- a/packages/client/src/translate.ts +++ b/packages/client/src/translate.ts @@ -1,27 +1,13 @@ /** - * Pure translators from the structured query DSL (cells' `query.fixture`: - * nodes/path/ego) into the parameterized `.gq` source that omnigraph-server - * speaks. Interim — to be superseded by predefined-query references - * (`query.ref`); see dash-books-canon.md §4. + * Translate a notebook mutation spec into omnigraph `.gq` source. * - * Output shape for every translator: - * { query_source: string, query_name: string, params: Record } - * - * - `query_source` is one .gq query block. - * - `query_name` matches the block's name so the server can pick it. - * - `params` is the bag of named parameters the .gq query references. - * - * Grammar mapped to: - * crates/omnigraph-compiler/src/query/query.pest:30-93 - * docs/query-language.md (MATCH/RETURN/ORDER/LIMIT + UPDATE) + * Reads no longer translate client-side — they invoke server-owned catalog + * queries by name (`ServerSource.read` → `og.queries.invoke`). Only the + * interim `set_field` write path still compiles `.gq` here; it moves + * server-side with the Phase 3 write model (dash-books-canon.md §4.5). */ -import type { - FixtureNodesQuery, - FixturePathQuery, - FixtureQuery, - MutationParams, -} from "@modernrelay/notebook-core"; +import type { MutationParams } from "@modernrelay/notebook-core"; export interface TranslatedQuery { query_source: string; @@ -36,239 +22,13 @@ export class UnsupportedTranslationError extends Error { } } -const COMP_OPS = new Set([">=", "<=", "!=", ">", "<", "="]); -const VAR_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const FIELD_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; -/** - * Convert PascalCase / snake_case / camelCase edge name to the .gq - * predicate form (lowercase first char of PascalCase). - * - * The .pg schema declares edges as PascalCase (e.g. `HasClause`); .gq - * queries reference them as `hasClause`. This mirrors the compiler's - * own naming convention. - */ -export function edgeToPredicate(name: string): string { - // First normalize to PascalCase, then lowercase the leading char. - const pascal = name.replace(/(^|[_-])([a-z])/g, (_, __, c) => c.toUpperCase()); - return pascal.charAt(0).toLowerCase() + pascal.slice(1); -} - -// ── translateNodesQuery ─────────────────────────────────────────────────── - -/** - * `kind: nodes` → MATCH a single node binding with optional where filters, - * RETURN the projected fields, optional ORDER BY + LIMIT. - * - * Note: a `nodes` query without a `where.type` becomes an unfiltered match - * over all nodes of the only-binding's implicit type — but .gq requires a - * type on every binding. We treat `where.type` as required at translation - * time and throw a clear error if it's missing. - */ -export function translateNodesQuery( - q: FixtureNodesQuery, - queryName = "ng_nodes", -): TranslatedQuery { - const typ = q.where?.type; - if (typeof typ !== "string" || !typ) { - throw new UnsupportedTranslationError( - "translateNodesQuery: `where.type` is required (server mode needs a typed binding for `match`)", - ); - } - const params: Record = {}; - const paramDecls: string[] = []; - const matchClauses: string[] = [`$n: ${typ}`]; - - for (const [k, v] of Object.entries(q.where ?? {})) { - if (k === "type") continue; - if (!FIELD_PATTERN.test(k)) { - throw new UnsupportedTranslationError( - `translateNodesQuery: invalid field name '${k}'`, - ); - } - const pname = paramName("w", k, params, paramDecls, v); - matchClauses.push(`$n.${k} = $${pname}`); - } - - const projectFields = q.project ?? ["id"]; - const returnList = projectFields - .map((f) => { - if (!FIELD_PATTERN.test(f)) { - throw new UnsupportedTranslationError( - `translateNodesQuery: invalid projection '${f}'`, - ); - } - return `$n.${f} as ${f}`; - }) - .join(", "); - - let body = `match {\n ${matchClauses.join("\n ")}\n}\nreturn { ${returnList} }`; - if (q.order_by) { - if (!FIELD_PATTERN.test(q.order_by.field)) { - throw new UnsupportedTranslationError( - `translateNodesQuery: invalid order_by field '${q.order_by.field}'`, - ); - } - body += `\norder { $n.${q.order_by.field} ${q.order_by.direction} }`; - } - if (q.limit !== undefined) { - body += `\nlimit ${Math.trunc(q.limit)}`; - } - - const decls = paramDecls.length > 0 ? `(${paramDecls.join(", ")})` : "()"; - return { - query_name: queryName, - query_source: `query ${queryName}${decls} {\n${body}\n}\n`, - params, - }; -} - -// ── translatePathQuery ──────────────────────────────────────────────────── - -/** - * `kind: path` → a single MATCH with multiple traversal clauses, plus a - * RETURN that projects literals and var-refs. - * - * Direction handling: forward edges become `$src predicate $dst`; reverse - * edges flip the source/target relationship by binding the upstream node - * as the *target* of the edge — which in .gq is the SAME `$src predicate - * $dst` shape, but the source lookup happens on what we call `dst`. We - * achieve this by swapping the source/target vars in the emitted clause. - */ -export function translatePathQuery( - q: FixturePathQuery, - queryName = "ng_path", -): TranslatedQuery { - const params: Record = {}; - const paramDecls: string[] = []; - const matchClauses: string[] = []; - const seenVars = new Set(); - - for (let i = 0; i < q.steps.length; i++) { - const step = q.steps[i]!; - if (!VAR_PATTERN.test(step.var)) { - throw new UnsupportedTranslationError( - `translatePathQuery: invalid var '${step.var}'`, - ); - } - if (seenVars.has(step.var)) { - throw new UnsupportedTranslationError( - `translatePathQuery: duplicate var '${step.var}'`, - ); - } - seenVars.add(step.var); - - if (i === 0) { - // First step: bind a typed node. - if (!step.type) { - throw new UnsupportedTranslationError( - "translatePathQuery: first step must declare `type`", - ); - } - matchClauses.push(`$${step.var}: ${step.type}`); - continue; - } - - if (!step.edge) { - throw new UnsupportedTranslationError( - `translatePathQuery: step '${step.var}' is not the first step and must declare an edge`, - ); - } - const prevVar = q.steps[i - 1]!.var; - const predicate = edgeToPredicate(step.edge); - const direction = step.direction ?? "out"; - - // Bind the new node first (so the type filter is visible). - if (step.type) { - matchClauses.push(`$${step.var}: ${step.type}`); - } - // Edge clause. Forward: prev -> step. Reverse: step -> prev. - if (direction === "out") { - matchClauses.push(`$${prevVar} ${predicate} $${step.var}`); - } else { - matchClauses.push(`$${step.var} ${predicate} $${prevVar}`); - } - } - - const returnParts: string[] = []; - for (const proj of q.project) { - if (proj.literal !== undefined) { - const pname = paramName("lit", proj.as, params, paramDecls, proj.literal); - returnParts.push(`$${pname} as ${proj.as}`); - continue; - } - if (proj.var === undefined) { - throw new UnsupportedTranslationError( - "translatePathQuery: projection needs `var` or `literal`", - ); - } - // `var.field` or bare `var` (we always require `.field` for path projections). - const dot = proj.var.indexOf("."); - if (dot < 0) { - throw new UnsupportedTranslationError( - `translatePathQuery: projection var '${proj.var}' must reference a field (e.g. 'a.title')`, - ); - } - const v = proj.var.slice(0, dot); - const f = proj.var.slice(dot + 1); - if (!seenVars.has(v) || !FIELD_PATTERN.test(f)) { - throw new UnsupportedTranslationError( - `translatePathQuery: invalid projection '${proj.var}'`, - ); - } - returnParts.push(`$${v}.${f} as ${proj.as}`); - } - - const decls = paramDecls.length > 0 ? `(${paramDecls.join(", ")})` : "()"; - const body = `match {\n ${matchClauses.join("\n ")}\n}\nreturn { ${returnParts.join(", ")} }`; - return { - query_name: queryName, - query_source: `query ${queryName}${decls} {\n${body}\n}\n`, - params, - }; -} - -// ── translateFixtureQuery (dispatch) ────────────────────────────────────── - -export function translateFixtureQuery( - q: FixtureQuery, - queryName?: string, -): TranslatedQuery { - // Cell ids can contain hyphens; .gq query names are identifiers only. - const safe = sanitizeQueryName(queryName); - switch (q.kind) { - case "nodes": - return translateNodesQuery(q, safe ?? "ng_nodes"); - case "path": - return translatePathQuery(q, safe ?? "ng_path"); - case "ego": - throw new UnsupportedTranslationError( - "translateFixtureQuery: `ego` is not supported in server mode for v0.7. " + - "Express the same intent with `kind: path` (one edge type) — multi-edge " + - "ego will land in v0.8 once the translator can emit unioned queries.", - ); - } -} - -/** - * `.gq` query names are identifiers (`[a-zA-Z_][a-zA-Z0-9_]*`). Cell ids - * can have hyphens; we replace them with underscores. Names that don't - * start with a letter/underscore get a `q_` prefix. - */ -function sanitizeQueryName(name: string | undefined): string | undefined { - if (name === undefined) return undefined; - let out = name.replace(/[^a-zA-Z0-9_]/g, "_"); - if (!/^[a-zA-Z_]/.test(out)) out = "q_" + out; - return out; -} - -// ── translateMutation ───────────────────────────────────────────────────── - /** * MutationParams → one `.gq` mutation. * * set_field { target_type, field, value, target_id } - * → update set { : $value } where id = $target_id + * → update set { : $value } where slug = $target_id * * Server commits one manifest version per call (atomic). */ @@ -287,9 +47,9 @@ export function translateMutation( // numeric/boolean field types land alongside richer mutation kinds. const decls = `($value: String, $target_id: String)`; // The server-side @key field is conventionally named `slug` (Lance - // reserves `id` for the row-id). Mutations always identify rows by - // slug; the cell author still writes `target_id` from the row's - // exposed `id` column (which is projected from `slug`). + // reserves `id` for the row-id). Mutations identify rows by slug; the + // cell author writes `target_id` from the row's exposed `id` column + // (projected from `slug`). const body = `update ${params.target_type} set { ${params.field}: $value } where slug = $target_id`; return { @@ -300,26 +60,3 @@ export function translateMutation( } } } - -// ── helpers ─────────────────────────────────────────────────────────────── - -function paramName( - prefix: string, - hint: string, - bag: Record, - decls: string[], - value: unknown, -): string { - let i = 0; - let name = `${prefix}_${hint}`; - while (name in bag) { - i += 1; - name = `${prefix}_${hint}_${i}`; - } - bag[name] = value; - decls.push(`$${name}: String`); - return name; -} - -// Make sure `COMP_OPS` is referenced (parking it for future where-op support). -void COMP_OPS; diff --git a/packages/core/src/runtime/compatibility.ts b/packages/core/src/runtime/compatibility.ts index 14347f6..d598052 100644 --- a/packages/core/src/runtime/compatibility.ts +++ b/packages/core/src/runtime/compatibility.ts @@ -20,22 +20,19 @@ export function validateNotebookCompatibility( const warnings: string[] = []; for (const cell of notebook.cells) { - if (cell.query?.source !== undefined) { + if (cell.query?.ref !== undefined && !capabilities.namedQueries) { + errors.push( + `${cell.id}: selected source does not support named catalog queries (query.ref)`, + ); + } + if (cell.query?.rawGq !== undefined) { warnings.push( - `${cell.id}: query.source raw .gq is deprecated; prefer query.fixture structured DSL`, + `${cell.id}: query.rawGq is a capability-gated escape hatch; prefer a catalog query.ref`, ); if (!capabilities.rawGq) { errors.push(`${cell.id}: selected source does not support raw .gq`); } } - if (cell.query?.fixture !== undefined) { - const kind = cell.query.fixture.kind; - if (!capabilities.structuredQueryKinds.includes(kind)) { - errors.push( - `${cell.id}: selected source does not support ${kind} queries`, - ); - } - } if (cell.query?.branch !== undefined && !capabilities.branchReads) { errors.push(`${cell.id}: selected source does not support branch reads`); } diff --git a/packages/core/src/runtime/index.test.ts b/packages/core/src/runtime/index.test.ts index 1904414..3bce282 100644 --- a/packages/core/src/runtime/index.test.ts +++ b/packages/core/src/runtime/index.test.ts @@ -14,7 +14,7 @@ import { } from "./index.js"; const FULL_CAPS: SourceCapabilities = { - structuredQueryKinds: ["nodes", "path", "ego"], + namedQueries: true, rawGq: true, mutationKinds: ["set_field"], branchReads: true, @@ -75,13 +75,13 @@ const NOTEBOOK: Notebook = { { id: "table", lens: "Table", - query: { source: "match (n) return n" }, + query: { ref: "table_q" }, props: { columns: [{ key: "x", label: "X" }] }, }, { id: "path", lens: "Path", - query: { source: "match (a)-[r]->(b) return a, r, b" }, + query: { ref: "path_q" }, props: { steps: [{ from_column: "a", predicate_column: "r", to_column: "b" }], }, @@ -90,42 +90,31 @@ const NOTEBOOK: Notebook = { }; describe("validateNotebookCompatibility", () => { - it("keeps raw .gq accepted but emits a deprecation warning", () => { - const result = validateNotebookCompatibility(NOTEBOOK, FULL_CAPS); + const RAWGQ_NB: Notebook = { + version: 1, + title: "raw", + cells: [ + { + id: "t", + lens: "Table", + query: { rawGq: "query q() { match { $d: Decision } return { $d.slug } }" }, + props: { columns: [{ key: "x", label: "X" }] }, + }, + ], + }; + + it("keeps query.rawGq accepted but emits an escape-hatch warning", () => { + const result = validateNotebookCompatibility(RAWGQ_NB, FULL_CAPS); expect(result.errors).toEqual([]); - expect(result.warnings.join("\n")).toMatch(/deprecated/); + expect(result.warnings.join("\n")).toMatch(/escape hatch/); }); - it("rejects unsupported structured query kinds before execution", () => { - const nb: Notebook = { - version: 1, - title: "x", - fixture: "./f.json", - cells: [ - { - id: "ego", - lens: "Subgraph", - query: { - fixture: { - kind: "ego", - center: { type: "Decision", where: {} }, - out: ["owns"], - in: [], - project: [{ var: "center.id", as: "id" }], - }, - }, - props: { - center: { type: "Decision", id_column: "id", label_column: "id" }, - depth: 1, - }, - }, - ], - }; - const result = validateNotebookCompatibility(nb, { + it("rejects query.ref when the source lacks named-query support", () => { + const result = validateNotebookCompatibility(NOTEBOOK, { ...FULL_CAPS, - structuredQueryKinds: ["nodes", "path"], + namedQueries: false, }); - expect(result.errors.join("\n")).toMatch(/ego/); + expect(result.errors.join("\n")).toMatch(/named catalog queries/); }); }); @@ -172,12 +161,7 @@ describe("createNotebookRuntime", () => { { id: "t", lens: "Table", - query: { - fixture: { - kind: "nodes", - where: { type: "X", status: { $state: "/f" } }, - }, - }, + query: { ref: "q", params: { status: { $state: "/f" } } }, props: { columns: [{ key: "x", label: "X" }] }, }, ], @@ -223,8 +207,20 @@ describe("createNotebookRuntime", () => { columns: [], rows: [], })); + const rawNb: Notebook = { + version: 1, + title: "raw", + cells: [ + { + id: "t", + lens: "Table", + query: { rawGq: "query q() { match { $d: Decision } return { $d.slug } }" }, + props: { columns: [{ key: "x", label: "X" }] }, + }, + ], + }; const runtime = createNotebookRuntime({ - notebook: NOTEBOOK, + notebook: rawNb, source: fakeSource({ capabilities: { ...FULL_CAPS, rawGq: false }, read, @@ -254,26 +250,20 @@ describe("createNotebookRuntime", () => { const nb: Notebook = { version: 1, title: "x", - fixture: "./f.json", cells: [ { id: "filtered", lens: "Table", query: { - fixture: { - kind: "nodes", - where: { - type: "Decision", - status: { $state: "/filters/status" }, - }, - }, + ref: "decisions", + params: { status: { $state: "/filters/status" } }, }, props: { columns: [{ key: "id", label: "ID" }] }, }, { id: "static", lens: "Table", - query: { fixture: { kind: "nodes", where: { type: "Issue" } } }, + query: { ref: "issues" }, props: { columns: [{ key: "id", label: "ID" }] }, }, ], @@ -305,19 +295,15 @@ describe("createNotebookRuntime", () => { const nb: Notebook = { version: 1, title: "x", - fixture: "./f.json", cells: [ { id: "filtered", lens: "Table", query: { - fixture: { - kind: "nodes", - where: { - type: "Decision", - status: { $state: "/filters/status" }, - urgency: { $state: "/filters/urgency" }, - }, + ref: "decisions", + params: { + status: { $state: "/filters/status" }, + urgency: { $state: "/filters/urgency" }, }, }, props: { columns: [{ key: "id", label: "ID" }] }, @@ -353,17 +339,11 @@ describe("createNotebookRuntime", () => { const nb: Notebook = { version: 1, title: "x", - fixture: "./f.json", cells: [ { id: "filtered", lens: "Table", - query: { - fixture: { - kind: "nodes", - where: { type: "Decision", status: { $state: "/f" } }, - }, - }, + query: { ref: "decisions", params: { status: { $state: "/f" } } }, props: { columns: [{ key: "x", label: "X" }] }, }, ], @@ -591,13 +571,12 @@ function actionListNotebook(target: { return { version: 1, title: "review", - fixture: "./f.json", cells: [ { id: "review", lens: "ActionList", query: { - fixture: { kind: "nodes", where: { type: "PolicyClause" } }, + ref: "policy_clauses", ...(target.branch !== undefined ? { branch: target.branch } : {}), ...(target.snapshot !== undefined ? { snapshot: target.snapshot } : {}), }, diff --git a/packages/core/src/runtime/resolve.ts b/packages/core/src/runtime/resolve.ts index 60c4adc..cacc240 100644 --- a/packages/core/src/runtime/resolve.ts +++ b/packages/core/src/runtime/resolve.ts @@ -1,4 +1,4 @@ -import type { Cell, FixtureQuery, Notebook } from "../spec/index.js"; +import type { Notebook } from "../spec/index.js"; import { isControl } from "./controls.js"; /** Map each data cell to the set of `$state` JSON pointers its query reads. */ @@ -9,8 +9,6 @@ export function dependencyMap(notebook: Notebook): Map> { const deps = new Set(); if (cell.query.params !== undefined) collectStatePointers(cell.query.params, deps); - if (cell.query.fixture !== undefined) - collectStatePointers(cell.query.fixture, deps); out.set(cell.id, deps); } return out; @@ -42,44 +40,6 @@ export function resolveParams( return out; } -export function resolveFixtureQuery( - query: FixtureQuery, - state: Record, -): FixtureQuery { - switch (query.kind) { - case "nodes": - return { - ...query, - ...(query.where !== undefined - ? { where: resolveWhere(query.where, state) } - : {}), - }; - case "ego": - return { - ...query, - center: { - ...query.center, - where: resolveWhere(query.center.where, state), - }, - }; - case "path": - return query; - } -} - -function resolveWhere( - where: Record, - state: Record, -): Record { - const out: Record = {}; - for (const [key, value] of Object.entries(where)) { - const resolved = resolveExpr(value, state); - if (resolved === null || resolved === undefined || resolved === "") continue; - out[key] = resolved; - } - return out; -} - function resolveExpr(value: unknown, state: Record): unknown { if ( value && diff --git a/packages/core/src/runtime/runtime.ts b/packages/core/src/runtime/runtime.ts index b88087a..86cda6f 100644 --- a/packages/core/src/runtime/runtime.ts +++ b/packages/core/src/runtime/runtime.ts @@ -24,7 +24,6 @@ import { import { dependencyMap, pointersOverlap, - resolveFixtureQuery, resolveParams, setAtPointer, } from "./resolve.js"; @@ -468,19 +467,14 @@ class NotebookRuntimeImpl implements NotebookRuntime { if (!cell.query) return { cellId: cell.id }; const target = this.readTargetForCell(cell); const request: ReadRequest = { cellId: cell.id }; - if (cell.query.source !== undefined) request.querySource = cell.query.source; + if (cell.query.ref !== undefined) request.queryRef = cell.query.ref; + if (cell.query.rawGq !== undefined) request.querySource = cell.query.rawGq; if (cell.query.name !== undefined) request.queryName = cell.query.name; if (cell.query.params !== undefined) { request.params = resolveParams(cell.query.params, this.snapshot.state); } if (target.branch !== undefined) request.branch = target.branch; if (target.snapshot !== undefined) request.snapshot = target.snapshot; - if (cell.query.fixture !== undefined) { - request.fixtureQuery = resolveFixtureQuery( - cell.query.fixture, - this.snapshot.state, - ); - } return request; } diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 699224d..c814afb 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -1,6 +1,5 @@ import type { Cell, - FixtureQuery, MutationParams, MutationResult, MutationSpec, @@ -8,11 +7,12 @@ import type { } from "../spec/index.js"; import type { LensSpec, QueryResult } from "../catalog/index.js"; -export type StructuredQueryKind = FixtureQuery["kind"]; export type MutationKind = MutationSpec["kind"]; export interface SourceCapabilities { - structuredQueryKinds: readonly StructuredQueryKind[]; + /** Source can invoke server-owned catalog queries by name (`query.ref`). */ + namedQueries: boolean; + /** Source accepts raw `.gq` source (`query.rawGq` escape hatch). */ rawGq: boolean; mutationKinds: readonly MutationKind[]; branchReads: boolean; @@ -27,12 +27,15 @@ export interface RuntimeTarget { export interface ReadRequest { cellId: string; + /** Catalog query name (`query.ref`) — invoked server-side by name. */ + queryRef?: string; + /** Raw `.gq` source (`query.rawGq` escape hatch). */ querySource?: string; + /** Selects a query within a multi-query `querySource` payload. */ queryName?: string; params?: Record; branch?: string; snapshot?: string; - fixtureQuery?: FixtureQuery; } export interface ReadOutput { diff --git a/packages/core/src/spec/index.test.ts b/packages/core/src/spec/index.test.ts index 52682f3..78322c4 100644 --- a/packages/core/src/spec/index.test.ts +++ b/packages/core/src/spec/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { parseNotebook } from "./index.js"; describe("parseNotebook", () => { - it("parses a server-mode notebook", () => { + it("parses a server-mode notebook with a catalog query ref", () => { const yaml = ` version: 1 title: Demo @@ -12,17 +12,18 @@ cells: - id: t lens: Table query: - fixture: { kind: nodes, where: { type: Decision }, project: [id, title] } + ref: decisions_by_urgency + params: { status: { $state: "/filters/status" } } props: { columns: [{ key: id, label: ID }] } `; const nb = parseNotebook(yaml); expect(nb.server).toBe("http://127.0.0.1:8080"); expect(nb.graph).toBe("company"); expect(nb.cells).toHaveLength(1); - expect(nb.cells[0]?.query.fixture?.kind).toBe("nodes"); + expect(nb.cells[0]?.query?.ref).toBe("decisions_by_urgency"); }); - it("keeps deprecated raw .gq server-mode query.source accepted", () => { + it("accepts the raw .gq escape hatch (query.rawGq)", () => { const yaml = ` version: 1 title: Server @@ -30,12 +31,12 @@ cells: - id: t lens: Table query: - source: "match (n: Decision) return n.id" + rawGq: "query q() { match { $d: Decision } return { $d.slug as id } }" branch: main props: { columns: [{ key: id, label: ID }] } `; const nb = parseNotebook(yaml); - expect(nb.cells[0]?.query.source).toContain("Decision"); + expect(nb.cells[0]?.query?.rawGq).toContain("Decision"); }); it("rejects unknown lens", () => { @@ -44,7 +45,7 @@ cells: version: 1 title: x cells: - - { id: t, lens: WhirlyGig, query: { source: "x" } } + - { id: t, lens: WhirlyGig, query: { ref: q } } `), ).toThrow(); }); @@ -57,12 +58,12 @@ title: x cells: - id: t lens: Table - query: { source: "x", branch: main, snapshot: v1 } + query: { ref: q, branch: main, snapshot: v1 } `), ).toThrow(); }); - it("rejects when both query.source and query.fixture are set", () => { + it("rejects when both query.ref and query.rawGq are set", () => { expect(() => parseNotebook(` version: 1 @@ -70,14 +71,12 @@ title: x cells: - id: t lens: Table - query: - source: "x" - fixture: { kind: nodes } + query: { ref: q, rawGq: "x" } `), ).toThrow(); }); - it("rejects when neither query.source nor query.fixture is set", () => { + it("rejects when neither query.ref nor query.rawGq is set", () => { expect(() => parseNotebook(` version: 1 @@ -90,59 +89,33 @@ cells: ).toThrow(); }); - it("accepts each fixture-query kind", () => { - const nodes = ` -version: 1 -title: x -fixture: ./f.json -cells: - - { id: t, lens: Table, query: { fixture: { kind: nodes, where: { type: D } } } } -`; - const path = ` + it("rejects a stale top-level fixture: key (strict schema)", () => { + expect(() => + parseNotebook(` version: 1 title: x fixture: ./f.json cells: - - id: p - lens: Path - query: - fixture: - kind: path - steps: - - { var: a, type: A } - - { edge: e, var: b, type: B } - project: - - { var: a.id, as: from } - - { literal: e, as: pred } - - { var: b.id, as: to } -`; - const ego = ` + - { id: t, lens: Table, query: { ref: q } } +`), + ).toThrow(); + }); + + it("rejects an unknown query key (strict schema)", () => { + expect(() => + parseNotebook(` version: 1 title: x -fixture: ./f.json cells: - - id: e - lens: Subgraph - query: - fixture: - kind: ego - center: { type: D, where: { id: x } } - out: [foo] - project: - - { var: center.id, as: id } - - { var: edge_type, as: predicate } - - { var: neighbor.id, as: neighbor } -`; - expect(parseNotebook(nodes).cells[0]?.query.fixture?.kind).toBe("nodes"); - expect(parseNotebook(path).cells[0]?.query.fixture?.kind).toBe("path"); - expect(parseNotebook(ego).cells[0]?.query.fixture?.kind).toBe("ego"); + - { id: t, lens: Table, query: { ref: q, bogus: 1 } } +`), + ).toThrow(); }); it("accepts a control cell (no query) with on + visible", () => { const yaml = ` version: 1 title: Controls -fixture: ./f.json cells: - id: filter lens: Select @@ -171,7 +144,6 @@ cells: parseNotebook(` version: 1 title: x -fixture: ./f.json cells: - { id: t, lens: Table, props: { columns: [{ key: x, label: X }] } } `), @@ -183,12 +155,11 @@ cells: parseNotebook(` version: 1 title: x -fixture: ./f.json cells: - id: b lens: Button props: { label: Hi } - query: { fixture: { kind: nodes } } + query: { ref: q } `), ).toThrow(); }); @@ -197,11 +168,10 @@ cells: const yaml = ` version: 1 title: x -fixture: ./f.json cells: - id: t lens: Table - query: { fixture: { kind: nodes } } + query: { ref: q } `; expect(parseNotebook(yaml).cells[0]?.props).toEqual({}); }); diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 7c40a85..6a5cd36 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -22,10 +22,12 @@ export const ComponentKind = z.enum([ export type ComponentKind = z.infer; /** Element-level action binding (mirrors json-render's shape). */ -export const ActionBindingSchema = z.object({ - action: z.string().min(1), - params: z.record(z.string(), z.unknown()).optional(), -}); +export const ActionBindingSchema = z + .object({ + action: z.string().min(1), + params: z.record(z.string(), z.unknown()).optional(), + }) + .strict(); export type ActionBinding = z.infer; // ── Mutation DSL ────────────────────────────────────────────────────────── @@ -59,111 +61,36 @@ export interface MutationResult { kind: "ok"; } -// ── Structured query DSL (interim) ──────────────────────────────────────── -// A cell's `query.fixture` carries a structured query (nodes/path/ego) that -// `@modernrelay/notebook-client` compiles to `.gq` for omnigraph-server. The -// `fixture` name is historical; this is slated to become a predefined-query -// reference (`query.ref`). See dash-books-canon.md §4. - -const FixtureNodesQuerySchema = z.object({ - kind: z.literal("nodes"), - where: z.record(z.string(), z.unknown()).optional(), - project: z.array(z.string()).optional(), - order_by: z - .object({ - field: z.string().min(1), - direction: z.enum(["asc", "desc"]).default("asc"), - }) - .optional(), - limit: z.number().int().positive().optional(), -}); - -const FixturePathStepSchema = z.object({ - /** Variable name bound by this step (every step binds its target node). */ - var: z.string().min(1), - /** Optional type filter on the bound node. */ - type: z.string().min(1).optional(), - /** Edge type for traversal. Required on every step except the first. */ - edge: z.string().min(1).optional(), - /** - * Traversal direction. - * - `out` (default): previous step's node is the edge source; this step - * binds the edge target. - * - `in`: previous step's node is the edge target; this step binds the - * edge source. Useful for "Decision ← owned by ← Actor" when `owns` - * is defined as Actor → Decision. - */ - direction: z.enum(["out", "in"]).default("out"), -}); - -const FixturePathProjectionSchema = z - .object({ - /** Variable + field reference, e.g. "s.title". Mutually exclusive with literal. */ - var: z.string().min(1).optional(), - /** Constant string value, useful for predicate labels. */ - literal: z.string().optional(), - as: z.string().min(1), - }) - .refine( - (p) => (p.var !== undefined) !== (p.literal !== undefined), - "exactly one of `var` or `literal` must be set", - ); - -const FixturePathQuerySchema = z.object({ - kind: z.literal("path"), - steps: z.array(FixturePathStepSchema).min(2), - project: z.array(FixturePathProjectionSchema).min(1), -}); - -const FixtureEgoProjectionSchema = z.object({ - /** One of: `center.`, `edge_type`, `edge_direction`, `neighbor.`, `neighbor_type`, `edge.`. */ - var: z.string().min(1), - as: z.string().min(1), -}); - -const FixtureEgoQuerySchema = z.object({ - kind: z.literal("ego"), - center: z.object({ - type: z.string().min(1), - where: z.record(z.string(), z.unknown()).default({}), - }), - out: z.array(z.string().min(1)).default([]), - in: z.array(z.string().min(1)).default([]), - project: z.array(FixtureEgoProjectionSchema).min(1), -}); - -export const FixtureQuerySchema = z.discriminatedUnion("kind", [ - FixtureNodesQuerySchema, - FixturePathQuerySchema, - FixtureEgoQuerySchema, -]); -export type FixtureQuery = z.infer; -export type FixtureNodesQuery = z.infer; -export type FixturePathQuery = z.infer; -export type FixtureEgoQuery = z.infer; - // ── Cell + Notebook ─────────────────────────────────────────────────────── const QuerySchema = z .object({ - // .gq mode (server). Deprecated escape hatch; prefer structured - // `query.fixture` so fixture/server sources can validate parity. - source: z.string().min(1).optional(), + /** + * Reference to a server-owned catalog query, invoked by name via the SDK's + * stored-query path (`og.queries.invoke`). The canonical, default path — + * the query body lives in the cluster catalog, never in the notebook. + */ + ref: z.string().min(1).optional(), + /** + * Raw `.gq` source — a capability-gated escape hatch for prototyping, + * debugging, and privileged one-offs. NOT the canonical contract; prefer + * `ref`. Sent ad-hoc via `og.query`. See dash-books-canon.md §4.2. + */ + rawGq: z.string().min(1).optional(), + /** Selects a query within a multi-query `rawGq` payload. */ name: z.string().optional(), params: z.record(z.string(), z.unknown()).optional(), branch: z.string().optional(), snapshot: z.string().optional(), - // Structured query DSL (nodes/path/ego), compiled to `.gq` for the - // server. Interim — to become a predefined-query `ref` (canon §4). - fixture: FixtureQuerySchema.optional(), }) + .strict() .refine( (q) => !(q.branch && q.snapshot), "query.branch and query.snapshot are mutually exclusive", ) .refine( - (q) => Boolean(q.source) !== Boolean(q.fixture), - "exactly one of query.source or query.fixture must be set", + (q) => Boolean(q.ref) !== Boolean(q.rawGq), + "exactly one of query.ref or query.rawGq must be set", ); /** @@ -174,13 +101,15 @@ const QuerySchema = z * Shape mirrors a control cell minus `query` (controls don't fetch data; * they read/write state via $bindState / on.press → setState). */ -export const CellControlSchema = z.object({ - id: z.string().min(1).optional(), - lens: ControlKind, - props: z.record(z.string(), z.unknown()).default({}), - on: z.record(z.string().min(1), ActionBindingSchema).optional(), - visible: z.unknown().optional(), -}); +export const CellControlSchema = z + .object({ + id: z.string().min(1).optional(), + lens: ControlKind, + props: z.record(z.string(), z.unknown()).default({}), + on: z.record(z.string().min(1), ActionBindingSchema).optional(), + visible: z.unknown().optional(), + }) + .strict(); export type CellControl = z.infer; export const CellSchema = z @@ -213,6 +142,7 @@ export const CellSchema = z */ visible: z.unknown().optional(), }) + .strict() .refine( (c) => { const isControl = @@ -226,19 +156,21 @@ export const CellSchema = z ); export type Cell = z.infer; -export const NotebookSchema = z.object({ - version: z.literal(1), - title: z.string().min(1), - /** omnigraph-server base URL when running in server mode. CLI flag overrides. */ - server: z.url().optional(), - /** - * Cluster graph id for server mode. omnigraph-server 0.7.0+ is cluster-only: - * reads/writes are served under `/graphs/{graph}/…`. Required in server mode; - * `--graph` (TUI), `?graph=` (web), or `OMNIGRAPH_GRAPH_ID` (TUI) override it. - */ - graph: z.string().min(1).optional(), - cells: z.array(CellSchema), -}); +export const NotebookSchema = z + .object({ + version: z.literal(1), + title: z.string().min(1), + /** omnigraph-server base URL (or operator-config server name). CLI/URL flags override. */ + server: z.string().min(1).optional(), + /** + * Cluster graph id for server mode. omnigraph-server 0.7.0+ is cluster-only: + * reads/writes are served under `/graphs/{graph}/…`. Required in server mode; + * `--graph` (TUI/CLI) or `?graph=` (web) override it. + */ + graph: z.string().min(1).optional(), + cells: z.array(CellSchema), + }) + .strict(); export type Notebook = z.infer; /** Parse a YAML or JSON string into a validated Notebook. Throws ZodError on failure. */ diff --git a/packages/web/src/App.test.ts b/packages/web/src/App.test.ts index 8a08cf6..dc0812b 100644 --- a/packages/web/src/App.test.ts +++ b/packages/web/src/App.test.ts @@ -29,8 +29,7 @@ graph: acme cells: - id: rows lens: Table - query: - fixture: { kind: nodes, where: { type: Decision } } + query: { ref: decisions_by_urgency } props: { columns: [{ key: id, label: ID }] } `); } diff --git a/scripts/server-demo.sh b/scripts/server-demo.sh index 3081353..ad6aa3a 100755 --- a/scripts/server-demo.sh +++ b/scripts/server-demo.sh @@ -38,6 +38,7 @@ SERVER_URL="http://${SERVER_BIND}" CLUSTER_DIR="${UI_REPO}/.server-demo/cluster" SCHEMA_SRC="${UI_REPO}/examples/server/company.pg" +QUERIES_SRC="${UI_REPO}/examples/server/queries" SEED_SRC="${UI_REPO}/examples/server/company.jsonl" log() { printf "==> %s\n" "$*"; } @@ -58,6 +59,7 @@ else log "Materializing fresh cluster at ${CLUSTER_DIR}" mkdir -p "$CLUSTER_DIR" cp "$SCHEMA_SRC" "${CLUSTER_DIR}/company.pg" + cp -R "$QUERIES_SRC" "${CLUSTER_DIR}/queries" cat > "${CLUSTER_DIR}/cluster.yaml" < Date: Mon, 22 Jun 2026 16:55:36 +0300 Subject: [PATCH 3/9] Connection: shared omnigraph operator-config resolver (Phase 1B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Node-only resolver (`@modernrelay/notebook-client/node`) that reads omnigraph's RFC-011 operator config — so a notebook connects with zero flags once you've `omnigraph login`'d (dash-books-canon.md §4.7). - operator.ts: ~/.omnigraph/config.yaml (servers/defaults/profiles) + ~/.omnigraph/credentials (INI, 0600-enforced); `--server ` resolves a name via the servers registry; token chain OMNIGRAPH_TOKEN_ → credentials[server] → OMNIGRAPH_BEARER_TOKEN; honors OMNIGRAPH_HOME / OMNIGRAPH_PROFILE; drops the invented OMNIGRAPH_TOKEN / OMNIGRAPH_GRAPH_ID - exported via a /node subpath (uses node:fs; out of the browser bundle) - cli/source.ts + tui/index.tsx delegate to the shared resolver; both gain --profile. web/config.ts unchanged (browser uses the view proxy) - client gains a `yaml` dep + 7 resolver tests Verified: pnpm -r build/typecheck/test green (56 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/args.ts | 4 +- packages/cli/src/source.ts | 60 +++++---- packages/client/package.json | 7 +- packages/client/src/operator.test.ts | 93 +++++++++++++ packages/client/src/operator.ts | 190 +++++++++++++++++++++++++++ packages/tui/src/index.tsx | 82 ++++++------ pnpm-lock.yaml | 3 + 7 files changed, 365 insertions(+), 74 deletions(-) create mode 100644 packages/client/src/operator.test.ts create mode 100644 packages/client/src/operator.ts diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index e435306..ebb41e0 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -4,12 +4,13 @@ import type { SourceOptions } from "./source.js"; type OptionConfig = NonNullable; -/** `--server/--token/--branch/--graph` — the source flags shared by most commands. */ +/** `--server/--graph/--token/--branch/--profile` — the source flags shared by most commands. */ export const SOURCE_OPTIONS: OptionConfig = { server: { type: "string" }, token: { type: "string" }, branch: { type: "string" }, graph: { type: "string" }, + profile: { type: "string" }, }; /** Pull the resolved source options out of a parseArgs `values` bag. */ @@ -21,5 +22,6 @@ export function sourceOptionsFrom( if (typeof values.token === "string") out.token = values.token; if (typeof values.branch === "string") out.branch = values.branch; if (typeof values.graph === "string") out.graph = values.graph; + if (typeof values.profile === "string") out.profile = values.profile; return out; } diff --git a/packages/cli/src/source.ts b/packages/cli/src/source.ts index 801d3d1..691f4fa 100644 --- a/packages/cli/src/source.ts +++ b/packages/cli/src/source.ts @@ -2,14 +2,18 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { Client, ServerSource } from "@modernrelay/notebook-client"; +import { resolveConnection as resolveOperatorConnection } from "@modernrelay/notebook-client/node"; import { parseNotebook, type Notebook } from "@modernrelay/notebook-core"; import type { Source } from "@modernrelay/notebook-core"; export interface SourceOptions { + /** `--server` — operator-config server name or a literal URL. */ server?: string; token?: string; branch?: string; graph?: string; + /** `--profile` — named operator-config profile. */ + profile?: string; } export interface LoadedNotebook { @@ -25,9 +29,9 @@ export function loadNotebook(notebookPath: string): LoadedNotebook { return { notebook: parseNotebook(yaml), notebookPath: abs }; } -/** Resolved connection params — the single source of truth for source selection. */ +/** Resolved connection — the single source of truth for source selection. */ export interface Connection { - /** Upstream omnigraph-server URL. */ + /** Resolved omnigraph-server base URL. */ server: string; token?: string; graphId: string; @@ -36,38 +40,38 @@ export interface Connection { } /** - * Resolve the omnigraph-server connection from a notebook + CLI/env options — - * the same selection the TUI uses (packages/tui/src/index.tsx). Server-mode - * graph-id precedence: flag → $OMNIGRAPH_GRAPH_ID → notebook. No I/O. + * Resolve the omnigraph-server connection by layering CLI flags over the + * omnigraph operator config (`~/.omnigraph/config.yaml` + `credentials`) and + * the notebook's declared `server`/`graph`. Shared with the TUI via + * `@modernrelay/notebook-client/node`. No graph I/O. */ export function resolveConnection( loaded: LoadedNotebook, opts: SourceOptions, ): Connection { - const { notebook } = loaded; - const server = opts.server ?? notebook.server; - if (!server) { - throw new Error( - "notebook has no `server:` (and no --server given)", - ); - } - const token = - opts.token ?? - process.env.OMNIGRAPH_TOKEN ?? - process.env.OMNIGRAPH_BEARER_TOKEN; - const graphId = opts.graph ?? process.env.OMNIGRAPH_GRAPH_ID ?? notebook.graph; - if (!graphId) { - throw new Error( - "server mode requires a graph id (omnigraph-server 0.7.0+ is cluster-only) — " + - "set `graph:` in the notebook, pass --graph , or set $OMNIGRAPH_GRAPH_ID", - ); - } + const r = resolveOperatorConnection( + { + ...(opts.server !== undefined ? { server: opts.server } : {}), + ...(opts.graph !== undefined ? { graph: opts.graph } : {}), + ...(opts.token !== undefined ? { token: opts.token } : {}), + ...(opts.branch !== undefined ? { branch: opts.branch } : {}), + ...(opts.profile !== undefined ? { profile: opts.profile } : {}), + }, + { + ...(loaded.notebook.server !== undefined + ? { server: loaded.notebook.server } + : {}), + ...(loaded.notebook.graph !== undefined + ? { graph: loaded.notebook.graph } + : {}), + }, + ); return { - server, - graphId, - label: `server: ${server} · graph: ${graphId}`, - ...(token !== undefined ? { token } : {}), - ...(opts.branch !== undefined ? { branch: opts.branch } : {}), + server: r.baseUrl, + graphId: r.graphId, + label: r.label, + ...(r.token !== undefined ? { token: r.token } : {}), + ...(r.branch !== undefined ? { branch: r.branch } : {}), }; } diff --git a/packages/client/package.json b/packages/client/package.json index ac8e76c..cff9ec3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -11,6 +11,10 @@ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./node": { + "import": "./dist/operator.js", + "types": "./dist/operator.d.ts" } }, "files": ["dist"], @@ -21,7 +25,8 @@ }, "dependencies": { "@modernrelay/omnigraph": "^0.7.0", - "@modernrelay/notebook-core": "workspace:*" + "@modernrelay/notebook-core": "workspace:*", + "yaml": "^2.8.4" }, "devDependencies": { "vitest": "^4.1.5" diff --git a/packages/client/src/operator.test.ts b/packages/client/src/operator.test.ts new file mode 100644 index 0000000..d6786db --- /dev/null +++ b/packages/client/src/operator.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveConnection } from "./operator.js"; + +const ENV_KEYS = [ + "OMNIGRAPH_HOME", + "OMNIGRAPH_PROFILE", + "OMNIGRAPH_BEARER_TOKEN", + "OMNIGRAPH_TOKEN_PROD", +]; + +function freshHome(): string { + const dir = mkdtempSync(join(tmpdir(), "og-home-")); + process.env.OMNIGRAPH_HOME = dir; + return dir; +} + +describe("resolveConnection", () => { + afterEach(() => { + for (const k of ENV_KEYS) delete process.env[k]; + }); + + it("uses a literal URL server + flag graph + bearer-token env", () => { + freshHome(); + process.env.OMNIGRAPH_BEARER_TOKEN = "tok"; + const r = resolveConnection({ server: "http://x.test", graph: "g" }, {}); + expect(r.baseUrl).toBe("http://x.test"); + expect(r.graphId).toBe("g"); + expect(r.token).toBe("tok"); + }); + + it("falls back to the notebook's server + graph", () => { + freshHome(); + const r = resolveConnection({}, { server: "http://nb.test", graph: "company" }); + expect(r.baseUrl).toBe("http://nb.test"); + expect(r.graphId).toBe("company"); + }); + + it("resolves a named server + keyed token from operator config", () => { + const dir = freshHome(); + writeFileSync( + join(dir, "config.yaml"), + `servers:\n prod: { url: https://prod.example }\ndefaults:\n server: prod\n default_graph: knowledge\n`, + ); + process.env.OMNIGRAPH_TOKEN_PROD = "keyed"; + const r = resolveConnection({}, {}); + expect(r.baseUrl).toBe("https://prod.example"); + expect(r.graphId).toBe("knowledge"); + expect(r.token).toBe("keyed"); + }); + + it("reads a token from the 0600 credentials file", () => { + const dir = freshHome(); + writeFileSync( + join(dir, "config.yaml"), + `servers:\n prod: { url: https://prod.example }\n`, + ); + const cred = join(dir, "credentials"); + writeFileSync(cred, `[prod]\ntoken = from-file\n`); + chmodSync(cred, 0o600); + const r = resolveConnection({ server: "prod", graph: "g" }, {}); + expect(r.token).toBe("from-file"); + }); + + it("throws on a missing graph", () => { + freshHome(); + expect(() => resolveConnection({ server: "http://x.test" }, {})).toThrow( + /graph id/, + ); + }); + + it("throws on a missing server", () => { + freshHome(); + expect(() => resolveConnection({ graph: "g" }, {})).toThrow(/no server/); + }); + + it("refuses an over-permissive credentials file", () => { + if (process.platform === "win32") return; + const dir = freshHome(); + writeFileSync( + join(dir, "config.yaml"), + `servers:\n prod: { url: https://prod.example }\n`, + ); + const cred = join(dir, "credentials"); + writeFileSync(cred, `[prod]\ntoken = x\n`); + chmodSync(cred, 0o644); + expect(() => resolveConnection({ server: "prod", graph: "g" }, {})).toThrow( + /over-permissive/, + ); + }); +}); diff --git a/packages/client/src/operator.ts b/packages/client/src/operator.ts new file mode 100644 index 0000000..b753b61 --- /dev/null +++ b/packages/client/src/operator.ts @@ -0,0 +1,190 @@ +/** + * Node-only omnigraph operator-config resolver (RFC-011). + * + * Reads the same client-side connection context the `omnigraph` CLI uses, so a + * notebook can connect with zero flags once you've `omnigraph login`'d: + * - `~/.omnigraph/config.yaml` — `servers` (name→URL), `defaults`, `profiles` + * - `~/.omnigraph/credentials` — INI, `0600`-enforced, `[server] token=…` + * + * Imported only via `@modernrelay/notebook-client/node` (uses `node:fs`), never + * from the browser bundle. The web app gets its connection from the `view` + * proxy / URL params instead. + */ + +import { readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +export interface ConnectionFlags { + /** `--server` — an operator-config server name OR a literal URL. */ + server?: string; + /** `--graph` — cluster graph id. */ + graph?: string; + /** `--token` — explicit bearer token (wins over config/env). */ + token?: string; + /** `--branch` — default read/write branch. */ + branch?: string; + /** `--profile` — named operator-config profile (else `$OMNIGRAPH_PROFILE`). */ + profile?: string; +} + +export interface ResolvedConnection { + baseUrl: string; + graphId: string; + token?: string; + branch?: string; + /** Human-readable summary for startup banners. */ + label: string; +} + +interface OperatorConfig { + servers: Record; + defaults: { server?: string; default_graph?: string }; + profiles: Record; +} + +/** `$OMNIGRAPH_HOME` (tilde-expanded) or `~/.omnigraph`. */ +function operatorHome(): string { + const home = process.env.OMNIGRAPH_HOME; + if (home && home.length > 0) { + return home.startsWith("~") ? join(homedir(), home.slice(1)) : home; + } + return join(homedir(), ".omnigraph"); +} + +function loadOperatorConfig(): OperatorConfig { + const empty: OperatorConfig = { servers: {}, defaults: {}, profiles: {} }; + let raw: unknown; + try { + raw = parseYaml(readFileSync(join(operatorHome(), "config.yaml"), "utf8")); + } catch { + return empty; // absent or unreadable config is not an error + } + if (!raw || typeof raw !== "object") return empty; + const obj = raw as Record; + return { + servers: (obj.servers as OperatorConfig["servers"]) ?? {}, + defaults: (obj.defaults as OperatorConfig["defaults"]) ?? {}, + profiles: (obj.profiles as OperatorConfig["profiles"]) ?? {}, + }; +} + +/** Parse `~/.omnigraph/credentials` (INI). Refuses an over-permissive file. */ +function loadCredentials(): Record { + const path = join(operatorHome(), "credentials"); + let stat: ReturnType; + try { + stat = statSync(path); + } catch { + return {}; // absent → no keyed tokens + } + // 0600: reject if group/other have any bits (POSIX only; mode is 0 on Windows). + if ((stat.mode & 0o077) !== 0 && process.platform !== "win32") { + throw new Error( + `${path} is over-permissive (mode ${(stat.mode & 0o777).toString(8)}); ` + + `omnigraph requires 0600 — run \`chmod 600 ${path}\``, + ); + } + const out: Record = {}; + let section = ""; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith(";")) + continue; + const sec = /^\[(.+)\]$/.exec(trimmed); + if (sec?.[1] !== undefined) { + section = sec[1].trim(); + continue; + } + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (key === "token" && section) out[section] = value; + } + return out; +} + +/** Keyed-token env var for a server name: `prod` → `OMNIGRAPH_TOKEN_PROD`. */ +function tokenEnvVar(serverName: string): string { + return `OMNIGRAPH_TOKEN_${serverName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`; +} + +function isUrl(s: string): boolean { + return s.startsWith("http://") || s.startsWith("https://") || s.startsWith("/"); +} + +/** + * Resolve a server-scope connection from flags + the notebook's declared + * `server`/`graph`, layering omnigraph operator config underneath. + * + * Precedence — server: flag → profile → defaults → notebook. A name resolves to + * a URL via `servers`; a literal URL is used as-is. graph: flag → profile/defaults + * → notebook. token: flag → `$OMNIGRAPH_TOKEN_` → credentials[server] → + * `$OMNIGRAPH_BEARER_TOKEN`. + */ +export function resolveConnection( + flags: ConnectionFlags, + notebook: { server?: string; graph?: string } = {}, +): ResolvedConnection { + const config = loadOperatorConfig(); + const profileName = flags.profile ?? process.env.OMNIGRAPH_PROFILE; + const profile = profileName ? config.profiles[profileName] : undefined; + if (profileName && !profile) { + throw new Error(`unknown operator profile '${profileName}' in config.yaml`); + } + + const serverRef = + flags.server ?? profile?.server ?? config.defaults.server ?? notebook.server; + if (!serverRef) { + throw new Error( + "no server: pass --server , set a profile/default in " + + "~/.omnigraph/config.yaml, or declare `server:` in the notebook", + ); + } + + // A literal URL is used directly (no name → no keyed token); a name resolves + // via the servers registry. + let baseUrl: string; + let serverName: string | undefined; + if (isUrl(serverRef)) { + baseUrl = serverRef; + } else { + const entry = config.servers[serverRef]; + if (!entry?.url) { + throw new Error( + `server '${serverRef}' is not defined in ~/.omnigraph/config.yaml servers:`, + ); + } + baseUrl = entry.url; + serverName = serverRef; + } + + const graphId = + flags.graph ?? + profile?.default_graph ?? + config.defaults.default_graph ?? + notebook.graph; + if (!graphId) { + throw new Error( + "server mode requires a graph id (omnigraph-server 0.7.0+ is cluster-only) — " + + "pass --graph , set default_graph in config.yaml, or declare `graph:` in the notebook", + ); + } + + const credentials = serverName ? loadCredentials() : {}; + const token = + flags.token ?? + (serverName ? process.env[tokenEnvVar(serverName)] : undefined) ?? + (serverName ? credentials[serverName] : undefined) ?? + process.env.OMNIGRAPH_BEARER_TOKEN; + + return { + baseUrl, + graphId, + ...(token !== undefined ? { token } : {}), + ...(flags.branch !== undefined ? { branch: flags.branch } : {}), + label: `server: ${serverName ? `${serverName} (${baseUrl})` : baseUrl} · graph: ${graphId}${flags.branch ? ` · ${flags.branch}` : ""}`, + }; +} diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index a22b672..c05af83 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -25,6 +25,7 @@ if (RAN_NON_TTY) { import { render } from "ink"; import { parseNotebook } from "@modernrelay/notebook-core"; import { Client, ServerSource } from "@modernrelay/notebook-client"; +import { resolveConnection } from "@modernrelay/notebook-client/node"; import type { Source } from "@modernrelay/notebook-core"; import { App } from "./App.js"; @@ -34,6 +35,7 @@ interface ParsedArgs { token?: string; branch?: string; graph?: string; + profile?: string; } function parseArgs(argv: readonly string[]): ParsedArgs { @@ -48,6 +50,8 @@ function parseArgs(argv: readonly string[]): ParsedArgs { out.branch = argv[++i]; } else if (a === "--graph") { out.graph = argv[++i]; + } else if (a === "--profile") { + out.profile = argv[++i]; } else if (a === "-h" || a === "--help") { printUsage(); process.exit(0); @@ -64,13 +68,13 @@ function parseArgs(argv: readonly string[]): ParsedArgs { function printUsage(): void { process.stderr.write(` -omnigraph-tui [--server URL] [--token TOKEN] [--branch NAME] [--graph ID] +omnigraph-tui [--server NAME|URL] [--graph ID] [--token TOKEN] [--branch NAME] [--profile NAME] - Reads + writes go to omnigraph-server (via the @modernrelay/omnigraph SDK) - when the notebook declares \`server: \` (or you pass --server). Bearer - token from --token or \$OMNIGRAPH_TOKEN. omnigraph-server 0.7.0+ is - cluster-only, so a graph id is required: set \`graph:\` in the notebook, - pass --graph, or set \$OMNIGRAPH_GRAPH_ID. + Reads + writes go to omnigraph-server via the @modernrelay/omnigraph SDK. + Connection resolves from flags, then omnigraph operator config + (~/.omnigraph/config.yaml + credentials), then the notebook's \`server:\`/ + \`graph:\`. With operator config set up (\`omnigraph login\`), no flags are + needed. omnigraph-server 0.7.0+ is cluster-only, so a graph id is required. `); } @@ -81,49 +85,39 @@ export function main(argv: readonly string[]): void { const yaml = readFileSync(notebookAbs, "utf8"); const notebook = parseNotebook(yaml); - // CLI flags > notebook fields. Falls back to env for token only. - // OMNIGRAPH_BEARER_TOKEN is the conventional omnigraph env var (server + - // CLI use it); accept it so plain `omnigraph-tui ` works without an - // OMNIGRAPH_TOKEN alias. - const serverUrl = args.server ?? notebook.server; - const token = - args.token ?? - process.env.OMNIGRAPH_TOKEN ?? - process.env.OMNIGRAPH_BEARER_TOKEN; - // omnigraph-server 0.7.0+ is cluster-only; every read/write is graph-scoped. - // Precedence: explicit flag → environment → committed notebook (most-specific - // / most-ephemeral wins, matching how `token` resolves above). - const graphId = - args.graph ?? process.env.OMNIGRAPH_GRAPH_ID ?? notebook.graph; - - let source: Source; - let label: string; - - if (serverUrl) { - if (!graphId) { - process.stderr.write( - `omnigraph-tui: server mode requires a graph id (omnigraph-server 0.7.0+\n` + - `is cluster-only). Set \`graph:\` in the notebook, pass --graph ,\n` + - `or set $OMNIGRAPH_GRAPH_ID.\n`, - ); - process.exit(2); - } - const client = new Client({ - baseUrl: serverUrl, - graphId, - ...(token !== undefined ? { token } : {}), - }); - source = new ServerSource(client, { - ...(args.branch !== undefined ? { branch: args.branch } : {}), - }); - label = `server: ${serverUrl} · graph: ${graphId}`; - } else { + let conn; + try { + conn = resolveConnection( + { + ...(args.server !== undefined ? { server: args.server } : {}), + ...(args.graph !== undefined ? { graph: args.graph } : {}), + ...(args.token !== undefined ? { token: args.token } : {}), + ...(args.branch !== undefined ? { branch: args.branch } : {}), + ...(args.profile !== undefined ? { profile: args.profile } : {}), + }, + { + ...(notebook.server !== undefined ? { server: notebook.server } : {}), + ...(notebook.graph !== undefined ? { graph: notebook.graph } : {}), + }, + ); + } catch (err) { process.stderr.write( - `omnigraph-tui: notebook has no \`server:\` and no --server flag was given.\n`, + `omnigraph-tui: ${err instanceof Error ? err.message : String(err)}\n`, ); process.exit(2); } + const client = new Client({ + baseUrl: conn.baseUrl, + graphId: conn.graphId, + ...(conn.token !== undefined ? { token: conn.token } : {}), + }); + const source: Source = new ServerSource( + client, + conn.branch !== undefined ? { branch: conn.branch } : {}, + ); + const label = conn.label; + render( Date: Mon, 22 Jun 2026 16:58:56 +0300 Subject: [PATCH 4/9] CLI DX: --version, per-command --help, render --watch, bin alias (Phase 1C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `--version` / `-v` (reads packages/cli/package.json at runtime) - per-command `--help` (`notebook --help`) with command-specific usage + the shared source-flags block; top-level HELP updated for the operator-config connection model (--server NAME|URL, --profile) - `render --watch` re-runs on notebook-file change (debounced; Ctrl-C to stop) - `omnigraph-notebook` bin alias (alongside notebook / mr-notebook) so a future Rust `omnigraph` plugin-dispatch resolves it - catalog command: replace the stale `queryKinds: [nodes,path,ego]` with the ref/rawGq query surface Deferred: live-reload `--watch` for view (browser) and tui (Ink) — needs websocket / reconciler work; tracked for a follow-up. Verified: pnpm -r build/typecheck/test green (56 tests); CLI --version/--help/validate/catalog/schema smoke-tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/package.json | 3 +- packages/cli/src/cli.ts | 72 ++++++++++++++++++++++++++-- packages/cli/src/commands/catalog.ts | 15 ++++-- packages/cli/src/commands/render.ts | 65 +++++++++++++++++++++---- 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 82acf15..47f004d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,7 +9,8 @@ }, "bin": { "notebook": "./dist/cli.js", - "mr-notebook": "./dist/cli.js" + "mr-notebook": "./dist/cli.js", + "omnigraph-notebook": "./dist/cli.js" }, "files": [ "dist", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 84e5f3b..6ca3050 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,10 +1,30 @@ +import { readFileSync } from "node:fs"; import { catalogCommand } from "./commands/catalog.js"; import { renderCommand } from "./commands/render.js"; import { schemaCommand } from "./commands/schema.js"; import { validateCommand } from "./commands/validate.js"; import { viewCommand } from "./commands/view.js"; -const HELP = `@modernrelay/notebook — run an omnigraph notebook anywhere +const VERSION = ((): string => { + try { + const pkg = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { version?: string }; + return pkg.version ?? "0.0.0"; + } catch { + return "0.0.0"; + } +})(); + +const SOURCE_FLAGS = `Source flags (view/tui/validate/render): + --server NAME|URL operator-config server name or a literal URL + --graph ID cluster graph id (omnigraph-server 0.7.0+ is cluster-only) + --token TOKEN bearer token (else ~/.omnigraph/credentials or env) + --branch NAME read/write branch + --profile NAME operator-config profile (else $OMNIGRAPH_PROFILE) +With ~/.omnigraph operator config set up (\`omnigraph login\`), no flags needed.`; + +const HELP = `@modernrelay/notebook v${VERSION} — run an omnigraph notebook anywhere Usage: notebook [args] (npx @modernrelay/notebook …) @@ -16,11 +36,41 @@ Commands: catalog Dump the lens/control/action schemas as JSON schema [--out FILE] Emit the notebook JSON Schema -Source flags (view/tui/validate/render): - --server URL --graph ID --token TOKEN --branch NAME - (graph id: --graph > $OMNIGRAPH_GRAPH_ID > notebook \`graph:\`) + --version, -v Print the version + --help, -h Print this help (or \` --help\`) + +${SOURCE_FLAGS} `; +const COMMAND_HELP: Record = { + view: `notebook view [--port N] [--no-open] [source flags] + Serve the prebuilt web SPA and open the notebook in a browser. In server + mode, /og reverse-proxies omnigraph-server with the token injected + server-side (the browser stays same-origin). + +${SOURCE_FLAGS}`, + tui: `notebook tui [source flags] + Render the notebook in the terminal (Ink). + +${SOURCE_FLAGS}`, + validate: `notebook validate [--json] [source flags] + Parse the notebook and capability-check it against the source. Exit 0 = valid, + 1 = invalid, 2 = usage. --json emits { ok, errors[], warnings? }.`, + render: `notebook render [--watch] [--timeout MS] [--compact] [source flags] + Headless run → each cell's resolved result as JSON. --watch re-runs on + notebook-file change (Ctrl-C to stop). + +${SOURCE_FLAGS}`, + catalog: `notebook catalog + Dump the lens/control/action prop schemas (author-facing) as JSON.`, + schema: `notebook schema [--out FILE] + Emit the notebook JSON Schema to stdout (or FILE).`, +}; + +function hasHelpFlag(rest: readonly string[]): boolean { + return rest.includes("--help") || rest.includes("-h"); +} + /** Lazy: only pulls in Ink/React (and its import-time stdin shim) for the TUI. */ async function tuiCommand(argv: string[]): Promise { const { main } = await import("@modernrelay/notebook-tui"); @@ -36,6 +86,14 @@ function isNotebookPath(arg: string): boolean { async function dispatch(argv: readonly string[]): Promise { const [command, ...rest] = argv; + + // ` --help` → that command's usage. + const cmdHelp = command !== undefined ? COMMAND_HELP[command] : undefined; + if (cmdHelp && hasHelpFlag(rest)) { + process.stdout.write(`${cmdHelp}\n`); + return 0; + } + switch (command) { case "view": return viewCommand(rest); @@ -49,6 +107,10 @@ async function dispatch(argv: readonly string[]): Promise { return catalogCommand(rest); case "schema": return schemaCommand(rest); + case "-v": + case "--version": + process.stdout.write(`${VERSION}\n`); + return 0; case undefined: case "help": case "-h": @@ -56,7 +118,7 @@ async function dispatch(argv: readonly string[]): Promise { process.stdout.write(HELP); return 0; default: - // Bare `mr-notebook some.notebook.yaml` → view it. + // Bare `notebook some.notebook.yaml` → view it. if (isNotebookPath(command)) return viewCommand([command, ...rest]); process.stderr.write(`unknown command: ${command}\n\n${HELP}`); return 2; diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index 2cbfdc9..bc6eb39 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -22,8 +22,9 @@ const AUTHOR_PROPS: Record = { /** * Machine-readable description of the catalog: every lens/control and the prop - * schema a notebook author writes, every action and its param schema, and the - * query kinds. Lets an agent discover the authoring surface without reading source. + * schema a notebook author writes, every action and its param schema, and how a + * cell binds a query. Lets an agent discover the authoring surface without + * reading source. */ export function catalogJson(): unknown { const lenses: Record = {}; @@ -40,7 +41,15 @@ export function catalogJson(): unknown { params: z.toJSONSchema(def.params, { io: "input" }), }; } - return { lenses, actions, queryKinds: ["nodes", "path", "ego"] }; + return { + lenses, + actions, + query: { + ref: "Name of a server-owned catalog query (the canonical path).", + rawGq: "Raw .gq source — a capability-gated escape hatch; prefer `ref`.", + note: "Exactly one of `ref` or `rawGq` per data cell; both accept `params`/`branch`/`snapshot`.", + }, + }; } export function catalogCommand(_argv: string[]): number { diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 1702558..bf84e41 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,16 +1,19 @@ +import { watch } from "node:fs"; import { parseArgs } from "node:util"; import { createNotebookRuntime } from "@modernrelay/notebook-core"; import { SOURCE_OPTIONS, sourceOptionsFrom } from "../args.js"; -import { buildSource, loadNotebook } from "../source.js"; +import { buildSource, loadNotebook, type SourceOptions } from "../source.js"; import { waitForSnapshot } from "../wait-for.js"; /** - * Headless run: execute every cell against its source and emit the resolved - * results as JSON — the agent equivalent of "screenshot the UI". Fixture mode is - * fully local; server mode needs a reachable server + graph + token. - * Exit 0 = ran, 1 = fatal (e.g. an incompatible notebook), 2 = usage. + * Headless run: execute every cell against omnigraph-server and emit the + * resolved results as JSON — the agent equivalent of "screenshot the UI". + * Needs a reachable server + graph (+ token under auth). Exit 0 = ran, + * 1 = fatal (e.g. an incompatible notebook), 2 = usage. + * + * `--watch` re-runs on notebook-file change and holds until Ctrl-C. */ export async function renderCommand(argv: string[]): Promise { const { positionals, values } = parseArgs({ @@ -20,12 +23,13 @@ export async function renderCommand(argv: string[]): Promise { ...SOURCE_OPTIONS, timeout: { type: "string" }, compact: { type: "boolean" }, + watch: { type: "boolean" }, }, }); const notebookPath = positionals[0]; if (!notebookPath) { process.stderr.write( - "usage: render [--server URL --graph ID --token T] [--timeout MS] [--compact]\n", + "usage: render [--server NAME|URL --graph ID --token T] [--watch] [--timeout MS] [--compact]\n", ); return 2; } @@ -35,9 +39,53 @@ export async function renderCommand(argv: string[]): Promise { typeof values.timeout === "string" ? Number(values.timeout) : Number.NaN; const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : 30_000; + const compact = values.compact === true; + const opts = sourceOptionsFrom(values); + if (values.watch !== true) { + return renderOnce(notebookPath, opts, timeoutMs, compact); + } + + // Watch mode: render now, then re-render on file change. Errors are reported + // but don't tear down the watcher. Holds until Ctrl-C. + await safeRender(notebookPath, opts, timeoutMs, compact); + let running = false; + watch(notebookPath, () => { + if (running) return; + running = true; + // Debounce editor atomic-saves (write + rename can fire twice). + setTimeout(() => { + void safeRender(notebookPath, opts, timeoutMs, compact).finally(() => { + running = false; + }); + }, 50); + }); + return new Promise(() => {}); +} + +async function safeRender( + notebookPath: string, + opts: SourceOptions, + timeoutMs: number, + compact: boolean, +): Promise { + try { + await renderOnce(notebookPath, opts, timeoutMs, compact); + } catch (err) { + process.stderr.write( + `render: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } +} + +async function renderOnce( + notebookPath: string, + opts: SourceOptions, + timeoutMs: number, + compact: boolean, +): Promise { const loaded = loadNotebook(notebookPath); - const { source } = buildSource(loaded, sourceOptionsFrom(values)); + const { source } = buildSource(loaded, opts); const runtime = createNotebookRuntime({ notebook: loaded.notebook, source }); try { const snapshot = await waitForSnapshot( @@ -56,8 +104,7 @@ export async function renderCommand(argv: string[]): Promise { result: c.result, })), }; - const indent = values.compact === true ? 0 : 2; - process.stdout.write(`${JSON.stringify(out, null, indent)}\n`); + process.stdout.write(`${JSON.stringify(out, null, compact ? 0 : 2)}\n`); return snapshot.status === "fatal" ? 1 : 0; } finally { runtime.dispose(); From 451d25163f566779758367f6b8ed3c69fa27059e Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 22 Jun 2026 17:02:00 +0300 Subject: [PATCH 5/9] Docs: refresh to the ref/rawGq query model + operator-config connection (Phase 1D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md / AGENTS.md: data-flow + Source sections describe `og.queries.invoke` by `ref` (and the `rawGq` escape hatch) instead of the deleted client-side DSL/translation; connection paragraph points at the shared @modernrelay/notebook-client/node operator-config resolver; drop the invented $OMNIGRAPH_GRAPH_ID; note the strict v1 schema - README.md: source-flags blurb covers --server NAME|URL + --profile and the flags → operator-config → notebook resolution order - dash-books-canon.md: tick off Phase 1 ledger items (query model, rawGq, strict schema, connection client, CLI DX, docs); mark the live-catalog validate and view/tui live-reload as partial, and the live-cluster e2e as pending Verified: pnpm -r build/typecheck/test green (56 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 10 +++++----- CLAUDE.md | 10 +++++----- README.md | 8 +++++--- dash-books-canon.md | 24 +++++++++++++----------- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2dbff73..709ffc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run | Package | Role | |---|---| -| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, structured query DSL, mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | +| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, query model — `ref`/`rawGq` — mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | | `@modernrelay/notebook-client` | **The only data source.** `ServerSource` + `translate` (structured DSL → `.gq`) + a `Client` facade over the `@modernrelay/omnigraph` SDK (`/query` + `/mutate`, graph-scoped). | | `@modernrelay/notebook-tui` | Ink renderer + CLI entry (`bin/omnigraph-tui.js`); host shell for terminal. | | `@modernrelay/notebook-web` | Vite + React + Tailwind renderer; host shell for browser. | @@ -63,7 +63,7 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run (ServerSource) → json-render Spec ``` -1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the structured query DSL (`nodes` / `path` / `ego`) and the `MutationSpec` discriminated union (currently only `set_field`). +1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the cell query model (`query.ref` → a server-owned catalog query, or `query.rawGq` raw `.gq` escape hatch) and the `MutationSpec` discriminated union (currently only `set_field`). The v1 schema is strict. 2. `@modernrelay/notebook-core`'s `createNotebookRuntime` validates notebook compatibility against `Source.capabilities()`, resolves `{ $state: "/ptr" }` expressions for data reads, invalidates only cells whose query dependencies changed, calls `Source.read()`, and hands results to `assembleLensSpec` (core's `catalog` module). Control cells skip reads and pass props through to `assembleControlSpec`. Per-cell errors are captured on `CellExecution.error`; runtime-level compatibility failures surface on `RuntimeSnapshot.error`. 3. core's `catalog` module exports `lensComponents` (Zod prop schemas + descriptions) and `lensActions` (`setState`, `mutate`). Author-time props are validated here; the renderer's `defineCatalog` consumes the same schemas. 4. The renderer (`packages/tui` or `packages/web`) calls `defineRegistry` against its UI library, supplying concrete Ink or React+Tailwind component implementations under the same component IDs (`Table`, `Path`, ...). The App subscribes to the runtime snapshot and passes each cell's `LensSpec` to ``. @@ -72,9 +72,9 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run Defined in `@modernrelay/notebook-core` (its `runtime` module) as a capability-aware contract: `capabilities()`, `read(request, context)`, and `mutate(command, context)`. There is one implementation; unsupported features fail during runtime compatibility validation or with explicit source errors: -- **`ServerSource`** (`@modernrelay/notebook-client`): the only source. Translates the structured DSL (`nodes`/`path`/`ego`) to `.gq` via `translateFixtureQuery`/`translateMutation` and calls the SDK's `query`/`mutate` (omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`). `ego` queries are decomposed into center/incident reads and merged client-side. Cells may bypass translation with deprecated `query.source` raw `.gq`. *(Target: cells reference predefined catalog queries by `ref` — see `dash-books-canon.md`.)* +- **`ServerSource`** (`@modernrelay/notebook-client`): the only source. Invokes server-owned catalog queries by name via the SDK's `og.queries.invoke` (`query.ref`, the default path), or sends raw `.gq` ad-hoc via `og.query` (`query.rawGq` escape hatch). `mutate` compiles the interim `set_field` to `.gq`. omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`. -Connection: `tui/src/index.tsx`, `web/src/config.ts`, and `cli/src/source.ts` build a `ServerSource` from `notebook.server` + `graph` (cluster id), with CLI flags or URL flags (`?server=...`, `?graph=...`, `?notebook=...`) as overrides. +Connection: `cli/src/source.ts` and `tui/src/index.tsx` resolve via the shared Node-only `@modernrelay/notebook-client/node` operator-config resolver (`~/.omnigraph/config.yaml` + `credentials`: named servers, profiles, keyed-token chain). Flags (`--server NAME|URL`/`--graph`/`--token`/`--branch`/`--profile`) and the notebook's `server`/`graph` layer in. `web/src/config.ts` stays on URL params + the `view` proxy — the browser can't read operator files. ### State + mutations @@ -96,6 +96,6 @@ Strict mode + `noUncheckedIndexedAccess`. All packages extend `tsconfig.base.jso ## Server-mode prerequisites -omnigraph-server 0.7.0+ is **cluster-only** (RFC-011): every read/write is served under `/graphs/{graph_id}/…`, so server-mode notebooks must carry a `graph:` id (overridable via `--graph`/`?graph=`/`$OMNIGRAPH_GRAPH_ID`). The SDK pins to a matching server line — `@modernrelay/omnigraph@^0.7.0` talks to a 0.7.x server only. +omnigraph-server 0.7.0+ is **cluster-only** (RFC-011): every read/write is served under `/graphs/{graph_id}/…`, so server-mode notebooks must carry a `graph:` id (overridable via `--graph`/`?graph=` or the operator-config `default_graph`). The SDK pins to a matching server line — `@modernrelay/omnigraph@^0.7.0` talks to a 0.7.x server only. `scripts/server-demo.sh` needs an omnigraph **v0.7.0+** checkout on disk — the sibling `../omnigraph` by default, or set `OMNIGRAPH_REPO`. It `cargo build`s `omnigraph-cli` + `omnigraph-server` (release), then materializes a **local filesystem-backed cluster** under `.server-demo/cluster` (graph `company`, schema `examples/server/company.pg`, seed `examples/server/company.jsonl`) via `cluster import`/`apply` + `load`, and boots `omnigraph-server --cluster … --unauthenticated` on `:8080` (PID/log under `.server-demo/`, gitignored). No RustFS/S3 required. Re-running reuses the cluster (mutations persist); delete `.server-demo` to reset. The demo runs unauthenticated, so bearer tokens are ignored; the web app reaches it same-origin through the Vite `/og` proxy (the 0.7.0 server sets no CORS headers). diff --git a/CLAUDE.md b/CLAUDE.md index c3ca041..7da9689 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run | Package | Role | |---|---| -| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, structured query DSL, mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | +| `@modernrelay/notebook-core` | The engine — start here. One package, three internal modules: `spec` (Zod schemas + YAML parser, query model — `ref`/`rawGq` — mutation specs), `catalog` (component+action definitions shared by both renderers; `assembleLensSpec` / `assembleControlSpec` produce json-render specs), `runtime` (capability-aware execution, state mirror, dependency invalidation, action dispatch, mutation lifecycle, optimistic reconciliation). The `@json-render/core` analog. | | `@modernrelay/notebook-client` | **The only data source.** `ServerSource` + `translate` (structured DSL → `.gq`) + a `Client` facade over the `@modernrelay/omnigraph` SDK (`/query` + `/mutate`, graph-scoped). | | `@modernrelay/notebook-tui` | Ink renderer + CLI entry (`bin/omnigraph-tui.js`); host shell for terminal. | | `@modernrelay/notebook-web` | Vite + React + Tailwind renderer; host shell for browser. | @@ -63,7 +63,7 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run (ServerSource) → json-render Spec ``` -1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the structured query DSL (`nodes` / `path` / `ego`) and the `MutationSpec` discriminated union (currently only `set_field`). +1. `@modernrelay/notebook-core`'s `spec` module parses+validates YAML against frozen v1 Zod schemas. Defines the cell query model (`query.ref` → a server-owned catalog query, or `query.rawGq` raw `.gq` escape hatch) and the `MutationSpec` discriminated union (currently only `set_field`). The v1 schema is strict. 2. `@modernrelay/notebook-core`'s `createNotebookRuntime` validates notebook compatibility against `Source.capabilities()`, resolves `{ $state: "/ptr" }` expressions for data reads, invalidates only cells whose query dependencies changed, calls `Source.read()`, and hands results to `assembleLensSpec` (core's `catalog` module). Control cells skip reads and pass props through to `assembleControlSpec`. Per-cell errors are captured on `CellExecution.error`; runtime-level compatibility failures surface on `RuntimeSnapshot.error`. 3. core's `catalog` module exports `lensComponents` (Zod prop schemas + descriptions) and `lensActions` (`setState`, `mutate`). Author-time props are validated here; the renderer's `defineCatalog` consumes the same schemas. 4. The renderer (`packages/tui` or `packages/web`) calls `defineRegistry` against its UI library, supplying concrete Ink or React+Tailwind component implementations under the same component IDs (`Table`, `Path`, ...). The App subscribes to the runtime snapshot and passes each cell's `LensSpec` to ``. @@ -72,9 +72,9 @@ The TUI consumes built `dist/` from sibling workspace packages — **always run Defined in `@modernrelay/notebook-core` (its `runtime` module) as a capability-aware contract: `capabilities()`, `read(request, context)`, and `mutate(command, context)`. There is one implementation; unsupported features fail during runtime compatibility validation or with explicit source errors: -- **`ServerSource`** (`@modernrelay/notebook-client`): the only source. Translates the structured DSL (`nodes`/`path`/`ego`) to `.gq` via `translateFixtureQuery`/`translateMutation` and calls the SDK's `query`/`mutate` (omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`). `ego` queries are decomposed into center/incident reads and merged client-side. Cells may bypass translation with deprecated `query.source` raw `.gq`. *(Target: cells reference predefined catalog queries by `ref` — see `dash-books-canon.md`.)* +- **`ServerSource`** (`@modernrelay/notebook-client`): the only source. Invokes server-owned catalog queries by name via the SDK's `og.queries.invoke` (`query.ref`, the default path), or sends raw `.gq` ad-hoc via `og.query` (`query.rawGq` escape hatch). `mutate` compiles the interim `set_field` to `.gq`. omnigraph-server 0.7.0+ serves these under `/graphs/{graph}/…`. -Connection: `tui/src/index.tsx`, `web/src/config.ts`, and `cli/src/source.ts` build a `ServerSource` from `notebook.server` + `graph` (cluster id), with CLI flags or URL flags (`?server=...`, `?graph=...`, `?notebook=...`) as overrides. +Connection: `cli/src/source.ts` and `tui/src/index.tsx` resolve via the shared Node-only `@modernrelay/notebook-client/node` operator-config resolver (`~/.omnigraph/config.yaml` + `credentials`: named servers, profiles, keyed-token chain). Flags (`--server NAME|URL`/`--graph`/`--token`/`--branch`/`--profile`) and the notebook's `server`/`graph` layer in. `web/src/config.ts` stays on URL params + the `view` proxy — the browser can't read operator files. ### State + mutations @@ -96,6 +96,6 @@ Strict mode + `noUncheckedIndexedAccess`. All packages extend `tsconfig.base.jso ## Server-mode prerequisites -omnigraph-server 0.7.0+ is **cluster-only** (RFC-011): every read/write is served under `/graphs/{graph_id}/…`, so server-mode notebooks must carry a `graph:` id (overridable via `--graph`/`?graph=`/`$OMNIGRAPH_GRAPH_ID`). The SDK pins to a matching server line — `@modernrelay/omnigraph@^0.7.0` talks to a 0.7.x server only. +omnigraph-server 0.7.0+ is **cluster-only** (RFC-011): every read/write is served under `/graphs/{graph_id}/…`, so server-mode notebooks must carry a `graph:` id (overridable via `--graph`/`?graph=` or the operator-config `default_graph`). The SDK pins to a matching server line — `@modernrelay/omnigraph@^0.7.0` talks to a 0.7.x server only. `scripts/server-demo.sh` needs an omnigraph **v0.7.0+** checkout on disk — the sibling `../omnigraph` by default, or set `OMNIGRAPH_REPO`. It `cargo build`s `omnigraph-cli` + `omnigraph-server` (release), then materializes a **local filesystem-backed cluster** under `.server-demo/cluster` (graph `company`, schema `examples/server/company.pg`, seed `examples/server/company.jsonl`) via `cluster import`/`apply` + `load`, and boots `omnigraph-server --cluster … --unauthenticated` on `:8080` (PID/log under `.server-demo/`, gitignored). No RustFS/S3 required. Re-running reuses the cluster (mutations persist); delete `.server-demo` to reset. The demo runs unauthenticated, so bearer tokens are ignored; the web app reaches it same-origin through the Vite `/og` proxy (the 0.7.0 server sets no CORS headers). diff --git a/README.md b/README.md index a9c742e..dfe46ed 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,11 @@ notebook view my.notebook.yaml `view` serves the prebuilt SPA locally and, in server mode, reverse-proxies the omnigraph-server with the bearer token injected server-side (the browser stays same-origin — omnigraph-server 0.7.0 sets no CORS headers, and the token never -reaches the page). Source flags (`--server/--graph/--token/--branch`) apply to -`view`/`tui`/`validate`/`render`; graph-id precedence is `--graph` → -`$OMNIGRAPH_GRAPH_ID` → notebook `graph:`. +reaches the page). Source flags (`--server NAME|URL` / `--graph` / `--token` / +`--branch` / `--profile`) apply to `view`/`tui`/`validate`/`render`; connection +resolves flags → omnigraph operator config (`~/.omnigraph/config.yaml` + +`credentials`) → the notebook's `server`/`graph`, so once you've `omnigraph +login`'d no flags are needed. ### Agent / scripting surface diff --git a/dash-books-canon.md b/dash-books-canon.md index 6fc703b..424b951 100644 --- a/dash-books-canon.md +++ b/dash-books-canon.md @@ -255,17 +255,19 @@ strict, single-version schema is safe; no version negotiation or legacy-v1 suppo - [x] Consolidate spec + catalog + runtime → `@modernrelay/notebook-core`. - [x] **Delete fixture mode** (package, top-level selection, example fixtures, source-selection). **Phase 1 — read-only canon (v1).** -- [ ] Cells reference catalog queries by `ref`; route `ServerSource.read` → `og.queries.invoke`; add - `ReadRequest.queryRef` plumbing; delete `translate.ts` + the `nodes`/`path`/`ego` DSL. -- [ ] `rawGq` escape hatch: capability-gated, off by default, validation warns. -- [ ] `notebook validate` against the catalog — `ref` resolves and params match `ParamDescriptor`s - (`og.queries.list()`). -- [ ] Make the schema strict; reject stale fixture-mode keys (internal tool — no version support). -- [ ] Operator-config connection client (shared Node resolver: config.yaml + credentials, named - servers, keyed tokens, profiles; browser uses the `view` proxy / dev config). -- [ ] Render explicit lenses over named queries; web-only components degrade to a **table** in the TUI. -- [ ] CLI DX: `--watch`, per-command help, `--version`, `omnigraph-notebook` bin alias. -- [ ] Refresh CLAUDE.md / README.md / AGENTS.md / server-demo to the post-fixture, predefined-query model. +- [x] Cells reference catalog queries by `ref`; `ServerSource.read` → `og.queries.invoke`; + `ReadRequest.queryRef` plumbing; deleted `translate.ts` read path + the `nodes`/`path`/`ego` DSL. +- [x] `rawGq` escape hatch: capability-gated; validation warns. +- [~] `notebook validate` parses + capability-checks; resolving `ref`/params against the live catalog + (`og.queries.list()`) is still TODO (needs a reachable server). +- [x] Strict schema; rejects stale fixture-mode keys (internal tool — no version support). +- [x] Operator-config connection client (shared `@modernrelay/notebook-client/node` resolver: + config.yaml + credentials, named servers, keyed tokens, profiles; browser uses the `view` proxy). +- [x] Render explicit lenses over named queries; web-only components degrade to a table in the TUI. +- [~] CLI DX: `--version`, per-command `--help`, `omnigraph-notebook` bin, `render --watch` done; + `view`/`tui` live-reload deferred. +- [x] Refresh CLAUDE.md / README.md / AGENTS.md / server-demo to the post-fixture, predefined-query model. +- [ ] End-to-end run against a live omnigraph cluster (`server-demo.sh` — cargo build + `og.queries.invoke`). **Cross-repo dependency for Phase 2 — declared query outputs (§4.3, decided: option a).** omnigraph extends the query catalog to declare output columns + types (`GET /queries`), mirroring params. Gates From 0a38be694b8e018ce34a9c66854bef093cb6afdd Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 23 Jun 2026 01:10:30 +0300 Subject: [PATCH 6/9] Add layout overlay tier (drawer/modal) + Timeline/Card lenses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2A: cell `display: drawer|modal` + `open_state` lifts detail cells into a host-shell overlay (web Drawer on base-ui Dialog; pure partitionCells in layout.ts groups cells sharing an open_state). Runtime/spec-nesting unchanged — reuses the /selected selection + applyStateChanges; the TUI stays layout-flat. Adds Timeline + Card lenses, click-to-select + wrap on Table, and fits tables to their container so the drawer doesn't overflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- dash-books-canon.md | 9 +- packages/cli/src/commands/catalog.ts | 4 + packages/core/src/catalog/index.test.ts | 4 +- packages/core/src/catalog/index.ts | 29 ++++ packages/core/src/catalog/lenses/card.ts | 30 ++++ packages/core/src/catalog/lenses/table.ts | 10 ++ packages/core/src/catalog/lenses/timeline.ts | 29 ++++ packages/core/src/spec/index.test.ts | 31 +++++ packages/core/src/spec/index.ts | 24 +++- packages/tui/src/App.tsx | 4 +- packages/tui/src/components/Card.tsx | 42 ++++++ packages/tui/src/components/Timeline.tsx | 49 +++++++ packages/tui/src/registry.ts | 4 +- packages/web/src/App.tsx | 139 +++++++++++++------ packages/web/src/components/Card.tsx | 45 ++++++ packages/web/src/components/Table.tsx | 70 ++++++++-- packages/web/src/components/Timeline.tsx | 48 +++++++ packages/web/src/components/ui/drawer.tsx | 76 ++++++++++ packages/web/src/layout.test.ts | 77 ++++++++++ packages/web/src/layout.ts | 61 ++++++++ packages/web/src/registry.ts | 4 +- refs/r1.jpg | Bin 0 -> 51761 bytes refs/r2.jpg | Bin 0 -> 84859 bytes 23 files changed, 729 insertions(+), 60 deletions(-) create mode 100644 packages/core/src/catalog/lenses/card.ts create mode 100644 packages/core/src/catalog/lenses/timeline.ts create mode 100644 packages/tui/src/components/Card.tsx create mode 100644 packages/tui/src/components/Timeline.tsx create mode 100644 packages/web/src/components/Card.tsx create mode 100644 packages/web/src/components/Timeline.tsx create mode 100644 packages/web/src/components/ui/drawer.tsx create mode 100644 packages/web/src/layout.test.ts create mode 100644 packages/web/src/layout.ts create mode 100644 refs/r1.jpg create mode 100644 refs/r2.jpg diff --git a/dash-books-canon.md b/dash-books-canon.md index 424b951..9e3f36f 100644 --- a/dash-books-canon.md +++ b/dash-books-canon.md @@ -276,7 +276,14 @@ the auto tier and output-binding validation; until it ships, v1 uses author-decl **Phase 2 — representation depth.** - [ ] Typed result envelopes (`rows` / `graph` / `tree`) with schema-derived metadata (pending the decision above). - [ ] Auto-render tier from result metadata; explicit `lens:` overrides. -- [ ] Curated, tested web-first component/layout catalog; TUI best-fit/degraded over the same contract. +- [~] Curated, tested web-first component/layout catalog; TUI best-fit/degraded over the same contract. + - [x] **2A — layout tier, first primitive.** Cell `display: inline|drawer|modal` + `open_state` + JSON-pointer: cells sharing a pointer lift into one overlay, open while that pointer is truthy + (the same `/selected` a Table writes on row click), close clears it. Host-shell only — no + json-render/spec/runtime change; reuses the selection state + `applyStateChanges`. Web renders + the drawer/modal (`web/src/layout.ts` `partitionCells` + `components/ui/drawer.tsx`); the TUI is + layout-flat and renders every cell inline. Lenses (Timeline, Card, wrap, click-to-select) landed + alongside. - [ ] Extend the catalog by in-tree, reviewed contribution (no third-party/sandboxed lenses); TUI renderer or table fallback per component. - [ ] Explicit dependency DAG (inputs, controls, query params, reads, cells) — no client expression layer. diff --git a/packages/cli/src/commands/catalog.ts b/packages/cli/src/commands/catalog.ts index bc6eb39..e5affd5 100644 --- a/packages/cli/src/commands/catalog.ts +++ b/packages/cli/src/commands/catalog.ts @@ -5,6 +5,8 @@ import { PathAuthorPropsSchema, SubgraphAuthorPropsSchema, TableAuthorPropsSchema, + TimelineAuthorPropsSchema, + CardAuthorPropsSchema, } from "@modernrelay/notebook-core"; import type { ZodType } from "zod"; import { z } from "zod"; @@ -18,6 +20,8 @@ const AUTHOR_PROPS: Record = { Path: PathAuthorPropsSchema, Subgraph: SubgraphAuthorPropsSchema, ActionList: ActionListAuthorPropsSchema, + Timeline: TimelineAuthorPropsSchema, + Card: CardAuthorPropsSchema, }; /** diff --git a/packages/core/src/catalog/index.test.ts b/packages/core/src/catalog/index.test.ts index de8e9f1..12af6cc 100644 --- a/packages/core/src/catalog/index.test.ts +++ b/packages/core/src/catalog/index.test.ts @@ -17,14 +17,16 @@ const fakeResult: QueryResult = { }; describe("lensComponents", () => { - it("registers all seven components (4 lenses + 3 controls)", () => { + it("registers all nine components (6 lenses + 3 controls)", () => { expect(Object.keys(lensComponents).sort()).toEqual([ "ActionList", "Button", + "Card", "Path", "Select", "Subgraph", "Table", + "Timeline", "Toggle", ]); }); diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index 096a3f6..c08cbd8 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -29,6 +29,20 @@ import { type ActionListAuthorProps, type ActionListRuntimeProps, } from "./lenses/action_list.js"; +import { + TimelineAuthorPropsSchema, + TimelineRuntimePropsSchema, + TimelineDescription, + type TimelineAuthorProps, + type TimelineRuntimeProps, +} from "./lenses/timeline.js"; +import { + CardAuthorPropsSchema, + CardRuntimePropsSchema, + CardDescription, + type CardAuthorProps, + type CardRuntimeProps, +} from "./lenses/card.js"; import { ButtonRuntimePropsSchema, ButtonDescription, @@ -46,6 +60,8 @@ export * from "./lenses/table.js"; export * from "./lenses/path.js"; export * from "./lenses/subgraph.js"; export * from "./lenses/action_list.js"; +export * from "./lenses/timeline.js"; +export * from "./lenses/card.js"; export * from "./lenses/button.js"; export * from "./lenses/toggle.js"; export * from "./lenses/select.js"; @@ -80,6 +96,8 @@ export const lensComponents = { Path: { props: PathRuntimePropsSchema, description: PathDescription }, Subgraph: { props: SubgraphRuntimePropsSchema, description: SubgraphDescription }, ActionList: { props: ActionListRuntimePropsSchema, description: ActionListDescription }, + Timeline: { props: TimelineRuntimePropsSchema, description: TimelineDescription }, + Card: { props: CardRuntimePropsSchema, description: CardDescription }, Button: { props: ButtonRuntimePropsSchema, description: ButtonDescription }, Toggle: { props: ToggleRuntimePropsSchema, description: ToggleDescription }, Select: { props: SelectRuntimePropsSchema, description: SelectDescription }, @@ -210,5 +228,16 @@ function buildRuntimeProps( const runtime: ActionListRuntimeProps = { ...author, rows: result.rows }; return runtime as unknown as Record; } + case "Timeline": { + const author: TimelineAuthorProps = + TimelineAuthorPropsSchema.parse(authorProps); + const runtime: TimelineRuntimeProps = { ...author, rows: result.rows }; + return runtime as unknown as Record; + } + case "Card": { + const author: CardAuthorProps = CardAuthorPropsSchema.parse(authorProps); + const runtime: CardRuntimeProps = { ...author, rows: result.rows }; + return runtime as unknown as Record; + } } } diff --git a/packages/core/src/catalog/lenses/card.ts b/packages/core/src/catalog/lenses/card.ts new file mode 100644 index 0000000..5baac0c --- /dev/null +++ b/packages/core/src/catalog/lenses/card.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +/** + * Node-detail card: renders the first row of its result as a labeled + * field list — "all data for one node". Pair with a single-node query + * (e.g. `get_concept($slug)`), typically driven by a selection in another + * cell (a Table's `select_state`, or a Select control). + */ +export const CardAuthorPropsSchema = z.object({ + /** Column used as the card heading (e.g. the node's name/title). */ + title_column: z.string().optional(), + /** + * Fields to show, in order. Omit to show every column in the row. + * `label` defaults to the key. + */ + fields: z + .array(z.object({ key: z.string().min(1), label: z.string().optional() })) + .optional(), + /** Shown when the query returns no row (e.g. nothing selected yet). */ + empty_text: z.string().optional(), +}); +export type CardAuthorProps = z.infer; + +export const CardRuntimePropsSchema = CardAuthorPropsSchema.extend({ + rows: z.array(z.record(z.string(), z.unknown())), +}); +export type CardRuntimeProps = z.infer; + +export const CardDescription = + "Detail card for a single node — renders the first result row as a titled, labeled field list. Drive it with a single-node query (often bound to a selection via $state)."; diff --git a/packages/core/src/catalog/lenses/table.ts b/packages/core/src/catalog/lenses/table.ts index b0425fd..e5f355e 100644 --- a/packages/core/src/catalog/lenses/table.ts +++ b/packages/core/src/catalog/lenses/table.ts @@ -7,10 +7,20 @@ export const TableAuthorPropsSchema = z.object({ key: z.string().min(1), label: z.string().min(1), format: z.enum(["text", "number", "json"]).optional(), + /** Wrap long prose instead of clipping it to one line. */ + wrap: z.boolean().optional(), }), ) .min(1), dense: z.boolean().optional(), + /** + * Make rows clickable: on click, write the row's `select_column` value to + * this JSON-pointer state path (e.g. "/selected"). Another cell (a Card) + * can read it via `$state` to show the selected node's detail. + */ + select_state: z.string().optional(), + /** Column whose value is written to `select_state` on row click. */ + select_column: z.string().optional(), }); export type TableAuthorProps = z.infer; diff --git a/packages/core/src/catalog/lenses/timeline.ts b/packages/core/src/catalog/lenses/timeline.ts new file mode 100644 index 0000000..a52d76e --- /dev/null +++ b/packages/core/src/catalog/lenses/timeline.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +/** + * Simple chronological activity feed. Each row is one event; the author maps + * columns to the event's actor / verb / target / timestamp / body. Ordering is + * the query's job (`order { … desc }`) — the lens renders rows as given. + * (Richer events — icons, refs, diffs, inline actions — come later.) + */ +export const TimelineAuthorPropsSchema = z.object({ + /** Column for the actor / author of the event. */ + actor_column: z.string().optional(), + /** Column for the verb / action (e.g. "added context", "made a change"). */ + verb_column: z.string().optional(), + /** Column for the target the event is about. */ + target_column: z.string().optional(), + /** Column for the timestamp (rendered as-is for now). */ + timestamp_column: z.string().optional(), + /** Column for an optional note/body shown beneath the event line. */ + body_column: z.string().optional(), +}); +export type TimelineAuthorProps = z.infer; + +export const TimelineRuntimePropsSchema = TimelineAuthorPropsSchema.extend({ + rows: z.array(z.record(z.string(), z.unknown())), +}); +export type TimelineRuntimeProps = z.infer; + +export const TimelineDescription = + "Renders rows as a chronological activity feed — one event per row with an actor, verb, target, timestamp, and optional body. Order the query server-side; rows render top-to-bottom as returned."; diff --git a/packages/core/src/spec/index.test.ts b/packages/core/src/spec/index.test.ts index 78322c4..84e12d4 100644 --- a/packages/core/src/spec/index.test.ts +++ b/packages/core/src/spec/index.test.ts @@ -139,6 +139,37 @@ cells: expect(nb.cells[1]?.on?.["press"]?.action).toBe("approve"); }); + it("parses a cell's layout-tier display + open_state", () => { + const yaml = ` +version: 1 +title: Detail drawer +cells: + - id: detail + lens: Card + query: { ref: get_concept } + display: drawer + open_state: /selected + props: { title_column: c.name } +`; + const nb = parseNotebook(yaml); + expect(nb.cells[0]?.display).toBe("drawer"); + expect(nb.cells[0]?.open_state).toBe("/selected"); + }); + + it("rejects an unknown display mode (enum)", () => { + expect(() => + parseNotebook(` +version: 1 +title: x +cells: + - id: t + lens: Card + query: { ref: q } + display: popover +`), + ).toThrow(); + }); + it("rejects a Table cell without a query", () => { expect(() => parseNotebook(` diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 6a5cd36..ea39a82 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -2,7 +2,14 @@ import { z } from "zod"; import { parse as parseYaml } from "yaml"; /** Data-bearing lenses (cells with a query). */ -export const LensKind = z.enum(["Table", "Subgraph", "Path", "ActionList"]); +export const LensKind = z.enum([ + "Table", + "Subgraph", + "Path", + "ActionList", + "Timeline", + "Card", +]); export type LensKind = z.infer; /** Interactive controls (cells without a query). */ @@ -15,6 +22,8 @@ export const ComponentKind = z.enum([ "Subgraph", "Path", "ActionList", + "Timeline", + "Card", "Button", "Toggle", "Select", @@ -141,6 +150,19 @@ export const CellSchema = z * Accepts a boolean, a state-condition object, or an array (AND). */ visible: z.unknown().optional(), + /** + * Presentation mode (host-shell layout tier, web-first). `inline` (default) + * stacks the cell in flow; `drawer`/`modal` render it in an overlay that is + * open while `open_state` is truthy. The TUI ignores this and renders inline. + */ + display: z.enum(["inline", "drawer", "modal"]).optional(), + /** + * JSON-pointer whose truthy value opens this cell's overlay (with `display: + * drawer|modal`). Cells sharing an `open_state` render in one overlay; the + * close affordance clears this pointer. Typically the selection pointer a + * Table writes via `select_state` (e.g. "/selected"). + */ + open_state: z.string().optional(), }) .strict() .refine( diff --git a/packages/tui/src/App.tsx b/packages/tui/src/App.tsx index 6682939..2354c4c 100644 --- a/packages/tui/src/App.tsx +++ b/packages/tui/src/App.tsx @@ -133,7 +133,9 @@ export function App({ {label} - {/* Tab strip: all cells, active highlighted */} + {/* Tab strip: all cells, active highlighted. The TUI is layout-flat: + it ignores a cell's `display: drawer|modal` (a web-only layout tier) + and renders every cell as an inline tab. */} {cellCount > 0 && ( {cells.map((c, i) => ( diff --git a/packages/tui/src/components/Card.tsx b/packages/tui/src/components/Card.tsx new file mode 100644 index 0000000..4043d8d --- /dev/null +++ b/packages/tui/src/components/Card.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { CardRuntimeProps } from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +function fmt(v: unknown): string { + if (v === null || v === undefined) return ""; + return typeof v === "object" ? JSON.stringify(v) : String(v); +} + +export function Card({ + props: p, +}: ComponentCtx): React.ReactElement { + const row = p.rows[0]; + if (!row) { + return ( + + {p.empty_text ?? "(nothing selected)"} + + ); + } + const fields = + p.fields ?? + Object.keys(row) + .filter((k) => k !== p.title_column) + .map((k) => ({ key: k, label: undefined as string | undefined })); + const title = p.title_column ? fmt(row[p.title_column]) : ""; + return ( + + {title && {title}} + {fields.map((f) => ( + + {f.label ?? f.key}: + {fmt(row[f.key])} + + ))} + + ); +} diff --git a/packages/tui/src/components/Timeline.tsx b/packages/tui/src/components/Timeline.tsx new file mode 100644 index 0000000..e68d650 --- /dev/null +++ b/packages/tui/src/components/Timeline.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { TimelineRuntimeProps } from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +function valueOf(row: Record, col: string | undefined): string { + if (!col) return ""; + const v = row[col]; + if (v === null || v === undefined) return ""; + return String(v); +} + +export function Timeline({ + props: p, +}: ComponentCtx): React.ReactElement { + const { rows, actor_column, verb_column, target_column, timestamp_column, body_column } = p; + if (rows.length === 0) { + return ( + + (no activity) + + ); + } + return ( + + {rows.map((row, idx) => { + const actor = valueOf(row, actor_column); + const verb = valueOf(row, verb_column); + const target = valueOf(row, target_column); + const ts = valueOf(row, timestamp_column); + const body = valueOf(row, body_column); + return ( + + + {actor && {actor} } + {verb && {verb} } + {target && {target}} + {ts && {` · ${ts}`}} + + {body && {body}} + + ); + })} + + ); +} diff --git a/packages/tui/src/registry.ts b/packages/tui/src/registry.ts index 3bd6e15..18aae9c 100644 --- a/packages/tui/src/registry.ts +++ b/packages/tui/src/registry.ts @@ -5,6 +5,8 @@ import { Table } from "./components/Table.js"; import { Path } from "./components/Path.js"; import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; +import { Timeline } from "./components/Timeline.js"; +import { Card } from "./components/Card.js"; import { Button } from "./components/Button.js"; import { Toggle } from "./components/Toggle.js"; import { Select } from "./components/Select.js"; @@ -15,7 +17,7 @@ const catalog = defineCatalog(schema, { }); const { registry: inkRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Button, Toggle, Select }, + components: { Table, Path, Subgraph, ActionList, Timeline, Card, Button, Toggle, Select }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { statePath: string; value: unknown }; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 19234c9..8fa0769 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -30,6 +30,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { Overlay } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; import { SearchIcon } from "lucide-react"; import { @@ -37,6 +38,7 @@ import { type CommandSection, } from "./components/CommandPalette.js"; import { useHotkeys, type Hotkey } from "./lib/hotkeys.js"; +import { partitionCells, readPointer } from "./layout.js"; type ConfigStatus = | { kind: "loading" } @@ -231,13 +233,55 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { message={snapshot.error ?? "runtime failed"} /> )} - {snapshot.status === "ready" && ( -

- {snapshot.cells.map((cell) => ( - - ))} -
- )} + {snapshot.status === "ready" && + (() => { + // Layout tier: most cells stack inline; cells with `display: + // drawer|modal` lift into an overlay keyed by their `open_state` + // pointer (cells sharing a pointer share one overlay). The overlay + // is open while that pointer is truthy in runtime state — the same + // `/selected` a Table writes on row click — and close clears it. + const { inline, overlays } = partitionCells(snapshot.cells); + return ( + <> +
+ {inline.map((cell) => ( + + ))} +
+ {overlays.map((group) => { + const selected = readPointer(snapshot.state, group.openState); + const title = + typeof selected === "string" && selected + ? humanizeCellId(selected) + : "Details"; + return ( + + runtime.applyStateChanges([ + { path: group.openState, value: "" }, + ]) + } + > +
+ {group.cells.map((cell) => ( +
+

+ {humanizeCellId(cell.cell.id)} +

+ +
+ ))} +
+
+ ); + })} + + ); + })()} {mutationError !== null && ( - {cell.controlSpecs.length > 0 && ( + + + + ); +} + +/** + * A cell's interior — inline filter controls + the lens output, with the + * pending/error/skeleton states. Shared by the inline `CellCard` and the + * overlay drawer so both render identically. + * + * Re-querying (filter change / mutation re-run) keeps the previous spec + * visible; we dim the lens output and show an "updating…" cue while the inline + * controls stay crisp. A failed re-read also keeps the stale spec, shown dimmed + * beneath the error. + */ +function CellBody({ cell }: { cell: CellExecution }): React.ReactElement { + const dimContent = (cell.pending || cell.error !== null) && cell.spec !== null; + return ( + <> + {cell.controlSpecs.length > 0 && ( +
+ {cell.controlSpecs.map((spec) => ( + + ))} +
+ )} +
+ {cell.error !== null && ( + + + {cell.error.message} + + + )} + {cell.spec !== null && ( + + )} + {cell.error === null && cell.spec === null && cell.pending && (
- {cell.controlSpecs.map((spec) => ( - - ))} + + +
)} -
- {cell.error !== null && ( - - - {cell.error.message} - - - )} - {cell.spec !== null && ( - - )} - {cell.error === null && cell.spec === null && cell.pending && ( -
- - - -
- )} -
- - +
+ ); } diff --git a/packages/web/src/components/Card.tsx b/packages/web/src/components/Card.tsx new file mode 100644 index 0000000..24b5ea2 --- /dev/null +++ b/packages/web/src/components/Card.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import type { CardRuntimeProps } from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +function fmt(v: unknown): string { + if (v === null || v === undefined) return ""; + return typeof v === "object" ? JSON.stringify(v) : String(v); +} + +export function Card({ + props: p, +}: ComponentCtx): React.ReactElement { + const row = p.rows[0]; + if (!row) { + return ( +

+ {p.empty_text ?? "(nothing selected)"} +

+ ); + } + const fields = + p.fields ?? + Object.keys(row) + .filter((k) => k !== p.title_column) + .map((k) => ({ key: k, label: undefined as string | undefined })); + const title = p.title_column ? fmt(row[p.title_column]) : ""; + return ( +
+ {title && ( +

{title}

+ )} +
+ {fields.map((f) => ( + +
{f.label ?? f.key}
+
{fmt(row[f.key])}
+
+ ))} +
+
+ ); +} diff --git a/packages/web/src/components/Table.tsx b/packages/web/src/components/Table.tsx index 047aac1..6fe8b3e 100644 --- a/packages/web/src/components/Table.tsx +++ b/packages/web/src/components/Table.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useActions, useStateValue } from "@json-render/react"; import type { TableRuntimeProps } from "@modernrelay/notebook-core"; import { Table as CossTable, @@ -29,10 +30,20 @@ function formatCell( export function Table({ props: p, }: ComponentCtx): React.ReactElement { - const { columns, rows, dense } = p; + const { columns, rows, dense, select_state, select_column } = p; + const actions = useActions(); + // Read the current selection so we can highlight the active row. + const selected = useStateValue(select_state ?? "/__never__"); + if (rows.length === 0) { return

(no rows)

; } + + // Rows are clickable only when the author opts in with both props. + const selectable = Boolean(select_state && select_column); + const rowValue = (row: Record): string => + select_column ? String(row[select_column] ?? "") : ""; + return ( @@ -43,18 +54,51 @@ export function Table({ - {rows.map((row, idx) => ( - - {columns.map((col) => ( - - {formatCell(row[col.key], col.format)} - - ))} - - ))} + {rows.map((row, idx) => { + const isSelected = selectable && rowValue(row) === selected; + return ( + + actions.execute({ + action: "setState", + params: { statePath: select_state, value: rowValue(row) }, + }), + } + : {})} + > + {columns.map((col) => ( + + {col.wrap ? ( +
+ {formatCell(row[col.key], col.format)} +
+ ) : ( + formatCell(row[col.key], col.format) + )} +
+ ))} +
+ ); + })}
); diff --git a/packages/web/src/components/Timeline.tsx b/packages/web/src/components/Timeline.tsx new file mode 100644 index 0000000..128fc51 --- /dev/null +++ b/packages/web/src/components/Timeline.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import type { TimelineRuntimeProps } from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +function valueOf(row: Record, col: string | undefined): string { + if (!col) return ""; + const v = row[col]; + if (v === null || v === undefined) return ""; + return String(v); +} + +export function Timeline({ + props: p, +}: ComponentCtx): React.ReactElement { + const { rows, actor_column, verb_column, target_column, timestamp_column, body_column } = p; + if (rows.length === 0) { + return

(no activity)

; + } + return ( +
    + {rows.map((row, idx) => { + const actor = valueOf(row, actor_column); + const verb = valueOf(row, verb_column); + const target = valueOf(row, target_column); + const ts = valueOf(row, timestamp_column); + const body = valueOf(row, body_column); + return ( +
  1. +
    + {actor && {actor}} + {verb && {verb}} + {target && {target}} + {ts && ( + {ts} + )} +
    + {body && ( +

    {body}

    + )} +
  2. + ); + })} +
+ ); +} diff --git a/packages/web/src/components/ui/drawer.tsx b/packages/web/src/components/ui/drawer.tsx new file mode 100644 index 0000000..b05f9b9 --- /dev/null +++ b/packages/web/src/components/ui/drawer.tsx @@ -0,0 +1,76 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +/** + * Overlay primitive for the notebook layout tier (cell `display: drawer|modal`). + * + * `drawer` slides a right-anchored panel in from the edge; `modal` fades a + * centered card. Both wrap base-ui `Dialog` and are fully controlled: the host + * shell drives `open` from notebook state (an `open_state` JSON-pointer) and + * clears that pointer in `onClose`. We pass `modal="trap-focus"` so the + * underlying cell stack stays scrollable behind the drawer. + */ +export function Overlay({ + open, + onClose, + title, + variant = "drawer", + children, +}: { + open: boolean; + onClose: () => void; + title: React.ReactNode; + variant?: "drawer" | "modal"; + children: React.ReactNode; +}): React.ReactElement { + return ( + { + if (!next) onClose(); + }} + modal="trap-focus" + > + + + +
+ + {title} + + + + +
+
{children}
+
+
+
+ ); +} diff --git a/packages/web/src/layout.test.ts b/packages/web/src/layout.test.ts new file mode 100644 index 0000000..84e1739 --- /dev/null +++ b/packages/web/src/layout.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import type { CellExecution } from "@modernrelay/notebook-core"; +import { partitionCells, readPointer } from "./layout.js"; + +/** Minimal CellExecution stub — partitionCells only reads `cell.cell`. */ +function cell( + id: string, + display?: "inline" | "drawer" | "modal", + open_state?: string, +): CellExecution { + return { cell: { id, lens: "Card", display, open_state } } as CellExecution; +} + +describe("partitionCells", () => { + it("keeps plain cells inline", () => { + const { inline, overlays } = partitionCells([cell("a"), cell("b")]); + expect(inline.map((c) => c.cell.id)).toEqual(["a", "b"]); + expect(overlays).toHaveLength(0); + }); + + it("groups cells sharing display+open_state into one overlay", () => { + const cells = [ + cell("list"), + cell("detail", "drawer", "/selected"), + cell("related", "drawer", "/selected"), + ]; + const { inline, overlays } = partitionCells(cells); + expect(inline.map((c) => c.cell.id)).toEqual(["list"]); + expect(overlays).toHaveLength(1); + expect(overlays[0]?.openState).toBe("/selected"); + expect(overlays[0]?.variant).toBe("drawer"); + expect(overlays[0]?.cells.map((c) => c.cell.id)).toEqual([ + "detail", + "related", + ]); + }); + + it("separates overlays by pointer and by variant", () => { + const { overlays } = partitionCells([ + cell("d1", "drawer", "/a"), + cell("d2", "drawer", "/b"), + cell("m1", "modal", "/a"), + ]); + expect(overlays).toHaveLength(3); + expect(overlays.map((o) => `${o.variant}:${o.openState}`)).toEqual([ + "drawer:/a", + "drawer:/b", + "modal:/a", + ]); + }); + + it("treats a non-inline cell with no open_state as inline (nothing opens it)", () => { + const { inline, overlays } = partitionCells([cell("x", "drawer")]); + expect(inline.map((c) => c.cell.id)).toEqual(["x"]); + expect(overlays).toHaveLength(0); + }); +}); + +describe("readPointer", () => { + it("reads a top-level pointer", () => { + expect(readPointer({ selected: "abc" }, "/selected")).toBe("abc"); + }); + + it("reads a nested pointer and unescapes tokens", () => { + expect(readPointer({ a: { "b/c": 1 } }, "/a/b~1c")).toBe(1); + }); + + it("returns undefined for a missing path", () => { + expect(readPointer({}, "/nope")).toBeUndefined(); + expect(readPointer({ a: 1 }, "/a/b")).toBeUndefined(); + }); + + it("returns the whole state for the empty pointer", () => { + const state = { x: 1 }; + expect(readPointer(state, "")).toBe(state); + }); +}); diff --git a/packages/web/src/layout.ts b/packages/web/src/layout.ts new file mode 100644 index 0000000..669b4b4 --- /dev/null +++ b/packages/web/src/layout.ts @@ -0,0 +1,61 @@ +import type { CellExecution } from "@modernrelay/notebook-core"; + +/** + * Layout tier (web host shell). Cells default to an inline vertical stack; a + * cell with `display: drawer|modal` + an `open_state` pointer lifts into an + * overlay that is open while that pointer is truthy in runtime state. This + * module holds the pure partition/pointer logic so it stays unit-testable apart + * from the React shell. See dash-books-canon.md §4.4. + */ + +/** An overlay (drawer/modal) is open while its `openState` pointer is truthy. */ +export interface OverlayGroup { + openState: string; + variant: "drawer" | "modal"; + cells: CellExecution[]; +} + +/** + * Split executed cells into the inline stack and overlay groups. A cell joins + * an overlay only when it declares both a non-inline `display` and an + * `open_state`; cells sharing a `display`+`open_state` collapse into one + * overlay (detail + related + … in a single drawer). Everything else — + * including a `drawer` cell with no `open_state`, which has nothing to open it + * — stays inline. Order is preserved within each bucket. + */ +export function partitionCells(cells: CellExecution[]): { + inline: CellExecution[]; + overlays: OverlayGroup[]; +} { + const inline: CellExecution[] = []; + const groups = new Map(); + for (const cell of cells) { + const display = cell.cell.display ?? "inline"; + const openState = cell.cell.open_state; + if (display === "inline" || !openState) { + inline.push(cell); + continue; + } + const key = `${display} ${openState}`; + const group = groups.get(key) ?? { openState, variant: display, cells: [] }; + group.cells.push(cell); + groups.set(key, group); + } + return { inline, overlays: [...groups.values()] }; +} + +/** Read a JSON-pointer (RFC 6901) value out of runtime state; undefined if absent. */ +export function readPointer( + state: Record, + pointer: string, +): unknown { + if (pointer === "") return state; + if (!pointer.startsWith("/")) return undefined; + let cur: unknown = state; + for (const raw of pointer.slice(1).split("/")) { + const key = raw.replace(/~1/g, "/").replace(/~0/g, "~"); + if (cur === null || typeof cur !== "object") return undefined; + cur = (cur as Record)[key]; + } + return cur; +} diff --git a/packages/web/src/registry.ts b/packages/web/src/registry.ts index 8a74925..20fe7b3 100644 --- a/packages/web/src/registry.ts +++ b/packages/web/src/registry.ts @@ -6,6 +6,8 @@ import { Table } from "./components/Table.js"; import { Path } from "./components/Path.js"; import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; +import { Timeline } from "./components/Timeline.js"; +import { Card } from "./components/Card.js"; import { Button } from "./components/Button.js"; import { Toggle } from "./components/Toggle.js"; import { Select } from "./components/Select.js"; @@ -18,7 +20,7 @@ const catalog = defineCatalog(schema, { // React's SetState is `(updater: (prev) => next) => void`. We use the // executor's setAtPointer helper to write at JSON-pointer paths immutably. const { registry: webRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Button, Toggle, Select }, + components: { Table, Path, Subgraph, ActionList, Timeline, Card, Button, Toggle, Select }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { diff --git a/refs/r1.jpg b/refs/r1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cdc91802894ccd584a12811846e3f29c60abbd35 GIT binary patch literal 51761 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 literal 0 HcmV?d00001 diff --git a/refs/r2.jpg b/refs/r2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..88fe6d73ab3ccc483d597ae36f710956903afb63 GIT binary patch literal 84859 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 literal 0 HcmV?d00001 From b522ce29fa7ff97718236fa5ef53657a0360cc1f Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 23 Jun 2026 02:13:52 +0300 Subject: [PATCH 7/9] Add in-flow layout grid (cell width) + Quote lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Phase 2 slices: - 2D in-flow grid: cell `width: full|half|third|two-thirds` maps to a span in the web host's responsive 6-column inline grid (layout.ts `widthToColSpan` → literal `md:col-span-*`; App.tsx stack → `grid md:grid-cols-6`). Default full = the old single-column stack; halves/thirds sit side-by-side. Host-shell only — the TUI stays layout-flat. - Quote lens: rows as a blockquote feed (`text_column` + a `source · meta…` citation) for highlights/annotations/comments — utterance-centric, distinct from Timeline. Web + Ink renderers; replaces the cramped highlights table. Both are host-shell/component-tier only — no runtime/spec-nesting/omnigraph change. Spec, catalog, tests, and canon updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- dash-books-canon.md | 11 ++++- packages/core/src/catalog/index.test.ts | 20 ++++++++- packages/core/src/catalog/index.ts | 14 ++++++ packages/core/src/catalog/lenses/quote.ts | 31 ++++++++++++++ packages/core/src/spec/index.test.ts | 28 ++++++++++++ packages/core/src/spec/index.ts | 9 ++++ packages/tui/src/App.tsx | 4 +- packages/tui/src/components/Quote.tsx | 47 ++++++++++++++++++++ packages/tui/src/registry.ts | 3 +- packages/web/src/App.tsx | 14 ++++-- packages/web/src/components/Quote.tsx | 52 +++++++++++++++++++++++ packages/web/src/layout.test.ts | 15 ++++++- packages/web/src/layout.ts | 29 +++++++++++-- packages/web/src/registry.ts | 3 +- 14 files changed, 266 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/catalog/lenses/quote.ts create mode 100644 packages/tui/src/components/Quote.tsx create mode 100644 packages/web/src/components/Quote.tsx diff --git a/dash-books-canon.md b/dash-books-canon.md index 9e3f36f..7d7a181 100644 --- a/dash-books-canon.md +++ b/dash-books-canon.md @@ -11,7 +11,7 @@ one typed result contract in a browser and a terminal.** A "dash-book" is a notebook: a YAML document whose cells are typed **lenses** (`Table`, `Path`, -`Subgraph`, `ActionList`) and **controls** (`Button`, `Toggle`, `Select`). You declare *what slice of +`Subgraph`, `ActionList`, `Timeline`, `Card`, `Quote`) and **controls** (`Button`, `Toggle`, `Select`). You declare *what slice of the graph to show and what actions to allow*; you never write UI code. The same YAML drives a React/Tailwind web renderer and an Ink terminal renderer from one shared result contract. The browser is the first-class rich renderer; the terminal is a useful degradation over the same data and action @@ -284,6 +284,15 @@ the auto tier and output-binding validation; until it ships, v1 uses author-decl the drawer/modal (`web/src/layout.ts` `partitionCells` + `components/ui/drawer.tsx`); the TUI is layout-flat and renders every cell inline. Lenses (Timeline, Card, wrap, click-to-select) landed alongside. + - [x] **2D (first slice) — in-flow layout grid.** Cell `width: full|half|third|two-thirds` sets the + cell's span in the web host's responsive 6-column inline grid (`web/src/layout.ts` + `widthToColSpan` → literal `md:col-span-*`; `App.tsx` inline stack → `grid md:grid-cols-6`). + Default `full` = the old single-column stack; halves/thirds sit side-by-side (two-pane + master-detail, KPI rows). Complements 2A's overlay tier; host-shell only, TUI ignores it (one + cell per tab). Collapses to one column below `md`. + - [x] **Quote lens.** Renders rows as a blockquote feed — `text_column` + a `source_column · meta…` + citation (`refs/r2.jpg`) — for highlights/annotations/comments. Utterance-centric, distinct from + Timeline (event feed). Replaces the cramped 2-column highlights table; web + Ink renderers. - [ ] Extend the catalog by in-tree, reviewed contribution (no third-party/sandboxed lenses); TUI renderer or table fallback per component. - [ ] Explicit dependency DAG (inputs, controls, query params, reads, cells) — no client expression layer. diff --git a/packages/core/src/catalog/index.test.ts b/packages/core/src/catalog/index.test.ts index 12af6cc..381b193 100644 --- a/packages/core/src/catalog/index.test.ts +++ b/packages/core/src/catalog/index.test.ts @@ -17,12 +17,13 @@ const fakeResult: QueryResult = { }; describe("lensComponents", () => { - it("registers all nine components (6 lenses + 3 controls)", () => { + it("registers all ten components (7 lenses + 3 controls)", () => { expect(Object.keys(lensComponents).sort()).toEqual([ "ActionList", "Button", "Card", "Path", + "Quote", "Select", "Subgraph", "Table", @@ -54,6 +55,19 @@ describe("assembleLensSpec", () => { ); }); + it("builds a Quote spec with rows merged in", () => { + const spec = assembleLensSpec( + "q1", + "Quote", + { text_column: "from", source_column: "to", meta_columns: ["p1"] }, + fakeResult, + ); + expect(spec.elements["q1"]?.type).toBe("Quote"); + expect((spec.elements["q1"]?.props as { rows: unknown[] }).rows).toEqual( + fakeResult.rows, + ); + }); + it("rejects malformed author props", () => { expect(() => assembleLensSpec("bad", "Table", { columns: [] }, fakeResult), @@ -61,5 +75,9 @@ describe("assembleLensSpec", () => { expect(() => assembleLensSpec("bad", "Path", { steps: [] }, fakeResult), ).toThrow(); + // Quote: meta_columns entries must be non-empty. + expect(() => + assembleLensSpec("bad", "Quote", { meta_columns: [""] }, fakeResult), + ).toThrow(); }); }); diff --git a/packages/core/src/catalog/index.ts b/packages/core/src/catalog/index.ts index c08cbd8..4264d9c 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -43,6 +43,13 @@ import { type CardAuthorProps, type CardRuntimeProps, } from "./lenses/card.js"; +import { + QuoteAuthorPropsSchema, + QuoteRuntimePropsSchema, + QuoteDescription, + type QuoteAuthorProps, + type QuoteRuntimeProps, +} from "./lenses/quote.js"; import { ButtonRuntimePropsSchema, ButtonDescription, @@ -62,6 +69,7 @@ export * from "./lenses/subgraph.js"; export * from "./lenses/action_list.js"; export * from "./lenses/timeline.js"; export * from "./lenses/card.js"; +export * from "./lenses/quote.js"; export * from "./lenses/button.js"; export * from "./lenses/toggle.js"; export * from "./lenses/select.js"; @@ -98,6 +106,7 @@ export const lensComponents = { ActionList: { props: ActionListRuntimePropsSchema, description: ActionListDescription }, Timeline: { props: TimelineRuntimePropsSchema, description: TimelineDescription }, Card: { props: CardRuntimePropsSchema, description: CardDescription }, + Quote: { props: QuoteRuntimePropsSchema, description: QuoteDescription }, Button: { props: ButtonRuntimePropsSchema, description: ButtonDescription }, Toggle: { props: ToggleRuntimePropsSchema, description: ToggleDescription }, Select: { props: SelectRuntimePropsSchema, description: SelectDescription }, @@ -239,5 +248,10 @@ function buildRuntimeProps( const runtime: CardRuntimeProps = { ...author, rows: result.rows }; return runtime as unknown as Record; } + case "Quote": { + const author: QuoteAuthorProps = QuoteAuthorPropsSchema.parse(authorProps); + const runtime: QuoteRuntimeProps = { ...author, rows: result.rows }; + return runtime as unknown as Record; + } } } diff --git a/packages/core/src/catalog/lenses/quote.ts b/packages/core/src/catalog/lenses/quote.ts new file mode 100644 index 0000000..356acb8 --- /dev/null +++ b/packages/core/src/catalog/lenses/quote.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +/** + * Quotation feed: renders each row as a blockquote of its text column with a + * source citation (+ optional metadata) beneath — for highlights, comments, + * annotations, or any text-with-provenance. Distinct from Timeline (an event + * feed of actor/verb/target): Quote is utterance-centric — the text dominates; + * the source is a small caption. + */ +export const QuoteAuthorPropsSchema = z.object({ + /** Column holding the quotation text (the dominant element). */ + text_column: z.string().optional(), + /** Column holding the source citation (e.g. an artifact title). */ + source_column: z.string().optional(), + /** + * Extra citation columns (e.g. author, year), shown after the source and + * joined with " · ". Empty/absent values are skipped. + */ + meta_columns: z.array(z.string().min(1)).optional(), + /** Shown when the query returns no rows. */ + empty_text: z.string().optional(), +}); +export type QuoteAuthorProps = z.infer; + +export const QuoteRuntimePropsSchema = QuoteAuthorPropsSchema.extend({ + rows: z.array(z.record(z.string(), z.unknown())), +}); +export type QuoteRuntimeProps = z.infer; + +export const QuoteDescription = + "Quotation feed — renders each row as a blockquote of its text column with a source citation and optional metadata (author, year) joined by ' · ' beneath. For highlights, comments, or annotations. Unlike Timeline (an actor/verb/target event feed), Quote is utterance-centric: the text dominates."; diff --git a/packages/core/src/spec/index.test.ts b/packages/core/src/spec/index.test.ts index 84e12d4..a41edb1 100644 --- a/packages/core/src/spec/index.test.ts +++ b/packages/core/src/spec/index.test.ts @@ -170,6 +170,34 @@ cells: ).toThrow(); }); + it("parses a cell's layout-grid width", () => { + const nb = parseNotebook(` +version: 1 +title: Grid +cells: + - id: a + lens: Table + query: { ref: q } + width: half + props: { columns: [{ key: x, label: X }] } +`); + expect(nb.cells[0]?.width).toBe("half"); + }); + + it("rejects an unknown width (enum)", () => { + expect(() => + parseNotebook(` +version: 1 +title: x +cells: + - id: t + lens: Table + query: { ref: q } + width: quarter +`), + ).toThrow(); + }); + it("rejects a Table cell without a query", () => { expect(() => parseNotebook(` diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index ea39a82..7376430 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -9,6 +9,7 @@ export const LensKind = z.enum([ "ActionList", "Timeline", "Card", + "Quote", ]); export type LensKind = z.infer; @@ -24,6 +25,7 @@ export const ComponentKind = z.enum([ "ActionList", "Timeline", "Card", + "Quote", "Button", "Toggle", "Select", @@ -163,6 +165,13 @@ export const CellSchema = z * Table writes via `select_state` (e.g. "/selected"). */ open_state: z.string().optional(), + /** + * In-flow layout width (host-shell layout tier, web-first). The web host + * arranges inline cells in a responsive 6-column grid; this sets the cell's + * column span — `full` (default, own row), `two-thirds`, `half`, `third`. + * Cells flow left-to-right and wrap. The TUI ignores this (one cell per tab). + */ + width: z.enum(["full", "half", "third", "two-thirds"]).optional(), }) .strict() .refine( diff --git a/packages/tui/src/App.tsx b/packages/tui/src/App.tsx index 2354c4c..4f1edee 100644 --- a/packages/tui/src/App.tsx +++ b/packages/tui/src/App.tsx @@ -134,8 +134,8 @@ export function App({ {/* Tab strip: all cells, active highlighted. The TUI is layout-flat: - it ignores a cell's `display: drawer|modal` (a web-only layout tier) - and renders every cell as an inline tab. */} + it ignores a cell's `display: drawer|modal` and `width` (web-only + layout tiers) and renders every cell as a full inline tab. */} {cellCount > 0 && ( {cells.map((c, i) => ( diff --git a/packages/tui/src/components/Quote.tsx b/packages/tui/src/components/Quote.tsx new file mode 100644 index 0000000..40fa85a --- /dev/null +++ b/packages/tui/src/components/Quote.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { QuoteRuntimeProps } from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +function valueOf(row: Record, col: string | undefined): string { + if (!col) return ""; + const v = row[col]; + if (v === null || v === undefined) return ""; + return typeof v === "object" ? JSON.stringify(v) : String(v); +} + +export function Quote({ + props: p, +}: ComponentCtx): React.ReactElement { + const { rows, text_column, source_column, meta_columns } = p; + if (rows.length === 0) { + return ( + + {p.empty_text ?? "(no quotes)"} + + ); + } + return ( + + {rows.map((row, idx) => { + const text = valueOf(row, text_column); + const cite = [source_column, ...(meta_columns ?? [])] + .map((c) => valueOf(row, c)) + .filter(Boolean) + .join(" · "); + return ( + + + {"┃ "} + {text} + + {cite && {` — ${cite}`}} + + ); + })} + + ); +} diff --git a/packages/tui/src/registry.ts b/packages/tui/src/registry.ts index 18aae9c..24eb914 100644 --- a/packages/tui/src/registry.ts +++ b/packages/tui/src/registry.ts @@ -7,6 +7,7 @@ import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; import { Timeline } from "./components/Timeline.js"; import { Card } from "./components/Card.js"; +import { Quote } from "./components/Quote.js"; import { Button } from "./components/Button.js"; import { Toggle } from "./components/Toggle.js"; import { Select } from "./components/Select.js"; @@ -17,7 +18,7 @@ const catalog = defineCatalog(schema, { }); const { registry: inkRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Timeline, Card, Button, Toggle, Select }, + components: { Table, Path, Subgraph, ActionList, Timeline, Card, Quote, Button, Toggle, Select }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { statePath: string; value: unknown }; diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 8fa0769..fb74e51 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -38,7 +38,7 @@ import { type CommandSection, } from "./components/CommandPalette.js"; import { useHotkeys, type Hotkey } from "./lib/hotkeys.js"; -import { partitionCells, readPointer } from "./layout.js"; +import { partitionCells, readPointer, widthToColSpan } from "./layout.js"; type ConfigStatus = | { kind: "loading" } @@ -243,7 +243,11 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { const { inline, overlays } = partitionCells(snapshot.cells); return ( <> -

+ {/* Inline cells flow in a responsive 6-column grid; each cell + spans per its `width` (default full = its own row, so the + no-width case is the old single-column stack). Collapses to + one column below md. */} +
{inline.map((cell) => ( ))} @@ -381,7 +385,11 @@ function CellCard({ cell }: { cell: CellExecution }): React.ReactElement { return ( diff --git a/packages/web/src/components/Quote.tsx b/packages/web/src/components/Quote.tsx new file mode 100644 index 0000000..67a8394 --- /dev/null +++ b/packages/web/src/components/Quote.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import type { QuoteRuntimeProps } from "@modernrelay/notebook-core"; + +interface ComponentCtx

{ + props: P; +} + +function valueOf(row: Record, col: string | undefined): string { + if (!col) return ""; + const v = row[col]; + if (v === null || v === undefined) return ""; + return typeof v === "object" ? JSON.stringify(v) : String(v); +} + +export function Quote({ + props: p, +}: ComponentCtx): React.ReactElement { + const { rows, text_column, source_column, meta_columns } = p; + if (rows.length === 0) { + return ( +

+ {p.empty_text ?? "(no quotes)"} +

+ ); + } + return ( +
    + {rows.map((row, idx) => { + const text = valueOf(row, text_column); + // Source first, then any extra metadata columns, joined by " · ". + const cite = [source_column, ...(meta_columns ?? [])] + .map((c) => valueOf(row, c)) + .filter(Boolean) + .join(" · "); + return ( +
  • +
    +
    + {text} +
    + {cite && ( +
    + {cite} +
    + )} +
    +
  • + ); + })} +
+ ); +} diff --git a/packages/web/src/layout.test.ts b/packages/web/src/layout.test.ts index 84e1739..e89c0c2 100644 --- a/packages/web/src/layout.test.ts +++ b/packages/web/src/layout.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { CellExecution } from "@modernrelay/notebook-core"; -import { partitionCells, readPointer } from "./layout.js"; +import { partitionCells, readPointer, widthToColSpan } from "./layout.js"; /** Minimal CellExecution stub — partitionCells only reads `cell.cell`. */ function cell( @@ -75,3 +75,16 @@ describe("readPointer", () => { expect(readPointer(state, "")).toBe(state); }); }); + +describe("widthToColSpan", () => { + it("maps each width to its literal 6-col span class", () => { + expect(widthToColSpan("two-thirds")).toBe("md:col-span-4"); + expect(widthToColSpan("half")).toBe("md:col-span-3"); + expect(widthToColSpan("third")).toBe("md:col-span-2"); + expect(widthToColSpan("full")).toBe("md:col-span-6"); + }); + + it("defaults absent width to a full row", () => { + expect(widthToColSpan(undefined)).toBe("md:col-span-6"); + }); +}); diff --git a/packages/web/src/layout.ts b/packages/web/src/layout.ts index 669b4b4..f88dde5 100644 --- a/packages/web/src/layout.ts +++ b/packages/web/src/layout.ts @@ -1,13 +1,34 @@ -import type { CellExecution } from "@modernrelay/notebook-core"; +import type { Cell, CellExecution } from "@modernrelay/notebook-core"; /** * Layout tier (web host shell). Cells default to an inline vertical stack; a * cell with `display: drawer|modal` + an `open_state` pointer lifts into an - * overlay that is open while that pointer is truthy in runtime state. This - * module holds the pure partition/pointer logic so it stays unit-testable apart - * from the React shell. See dash-books-canon.md §4.4. + * overlay that is open while that pointer is truthy in runtime state, and a + * cell's `width` sets its span in the inline responsive grid. This module holds + * the pure partition/pointer/width logic so it stays unit-testable apart from + * the React shell. See dash-books-canon.md §4.4. */ +/** + * Map a cell `width` to its Tailwind column span in the inline 6-column grid + * (`md:grid-cols-6`). Returns **complete literal class strings** from a lookup + * (never interpolated) so the Tailwind JIT scanner emits them. `full`/absent → + * a whole row; `two-thirds`/`half`/`third` divide the 6 columns evenly. + */ +export function widthToColSpan(width: Cell["width"]): string { + switch (width) { + case "two-thirds": + return "md:col-span-4"; + case "half": + return "md:col-span-3"; + case "third": + return "md:col-span-2"; + case "full": + case undefined: + return "md:col-span-6"; + } +} + /** An overlay (drawer/modal) is open while its `openState` pointer is truthy. */ export interface OverlayGroup { openState: string; diff --git a/packages/web/src/registry.ts b/packages/web/src/registry.ts index 20fe7b3..d60cf68 100644 --- a/packages/web/src/registry.ts +++ b/packages/web/src/registry.ts @@ -8,6 +8,7 @@ import { Subgraph } from "./components/Subgraph.js"; import { ActionList } from "./components/ActionList.js"; import { Timeline } from "./components/Timeline.js"; import { Card } from "./components/Card.js"; +import { Quote } from "./components/Quote.js"; import { Button } from "./components/Button.js"; import { Toggle } from "./components/Toggle.js"; import { Select } from "./components/Select.js"; @@ -20,7 +21,7 @@ const catalog = defineCatalog(schema, { // React's SetState is `(updater: (prev) => next) => void`. We use the // executor's setAtPointer helper to write at JSON-pointer paths immutably. const { registry: webRegistry } = defineRegistry(catalog, { - components: { Table, Path, Subgraph, ActionList, Timeline, Card, Button, Toggle, Select }, + components: { Table, Path, Subgraph, ActionList, Timeline, Card, Quote, Button, Toggle, Select }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { From 28a76207a5dfb9a1629fe8a7139ad0ee4e5645ac Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 23 Jun 2026 19:27:18 +0300 Subject: [PATCH 8/9] Harden BFF token handling + gate rawGq off by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three security/canon-alignment fixes: - P1 (BFF): the `view` proxy is now authoritative for auth — it always strips client-supplied Authorization/Proxy-Authorization and injects only the server-side token; the browser no longer holds a default `devtoken`. - P2 (rawGq): the raw `.gq` escape hatch is capability-gated and off by default (`ServerSource.allowRawGq`). A notebook with a `rawGq` cell now fails compatibility unless the explicit dev/CLI hatch is enabled (`--allow-raw-gq`, threaded through args/source/view→URL/tui; web `?allowRawGq`). - P3: `Client` reads no env — dropped the invented `OMNIGRAPH_TOKEN` (and the redundant `OMNIGRAPH_BEARER_TOKEN`) fallback; token resolution lives only in the shared operator resolver. Tests + canon updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- dash-books-canon.md | 8 +++++++- packages/cli/src/args.ts | 4 +++- packages/cli/src/cli.ts | 1 + packages/cli/src/commands/view.ts | 4 +++- packages/cli/src/proxy.ts | 20 ++++++++++++++------ packages/cli/src/serve.ts | 12 ++++++++++-- packages/cli/src/source.ts | 3 +++ packages/client/src/http.ts | 14 +++++++++----- packages/client/src/source.test.ts | 10 ++++++++-- packages/client/src/source.ts | 11 ++++++++++- packages/core/src/runtime/compatibility.ts | 13 +++++++++---- packages/tui/src/index.tsx | 13 ++++++++----- packages/web/src/App.test.ts | 9 ++++++++- packages/web/src/config.ts | 19 +++++++++++++++++-- 14 files changed, 110 insertions(+), 31 deletions(-) diff --git a/dash-books-canon.md b/dash-books-canon.md index 7d7a181..b60f5e0 100644 --- a/dash-books-canon.md +++ b/dash-books-canon.md @@ -257,7 +257,13 @@ strict, single-version schema is safe; no version negotiation or legacy-v1 suppo **Phase 1 — read-only canon (v1).** - [x] Cells reference catalog queries by `ref`; `ServerSource.read` → `og.queries.invoke`; `ReadRequest.queryRef` plumbing; deleted `translate.ts` read path + the `nodes`/`path`/`ego` DSL. -- [x] `rawGq` escape hatch: capability-gated; validation warns. +- [x] `rawGq` escape hatch: capability-gated and **off by default** (`ServerSource.allowRawGq`); a notebook + with a `rawGq` cell fails compatibility unless the explicit dev/CLI hatch is on (`--allow-raw-gq`, + `?allowRawGq`). When enabled, validation still warns. (§4.2) +- [x] BFF/token hardening (§4.7): the `view` proxy is authoritative for auth — it always strips client + `Authorization`/`Proxy-Authorization` and injects only the server-side token; the browser holds no + default token (no `devtoken`); the `Client` reads no env (resolution lives only in the operator + resolver — `OMNIGRAPH_TOKEN`/`OMNIGRAPH_GRAPH_ID` are gone). - [~] `notebook validate` parses + capability-checks; resolving `ref`/params against the live catalog (`og.queries.list()`) is still TODO (needs a reachable server). - [x] Strict schema; rejects stale fixture-mode keys (internal tool — no version support). diff --git a/packages/cli/src/args.ts b/packages/cli/src/args.ts index ebb41e0..b7705b6 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -4,13 +4,14 @@ import type { SourceOptions } from "./source.js"; type OptionConfig = NonNullable; -/** `--server/--graph/--token/--branch/--profile` — the source flags shared by most commands. */ +/** `--server/--graph/--token/--branch/--profile` + `--allow-raw-gq` — the source flags shared by most commands. */ export const SOURCE_OPTIONS: OptionConfig = { server: { type: "string" }, token: { type: "string" }, branch: { type: "string" }, graph: { type: "string" }, profile: { type: "string" }, + "allow-raw-gq": { type: "boolean" }, }; /** Pull the resolved source options out of a parseArgs `values` bag. */ @@ -23,5 +24,6 @@ export function sourceOptionsFrom( if (typeof values.branch === "string") out.branch = values.branch; if (typeof values.graph === "string") out.graph = values.graph; if (typeof values.profile === "string") out.profile = values.profile; + if (values["allow-raw-gq"] === true) out.allowRawGq = true; return out; } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6ca3050..bbc4820 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -22,6 +22,7 @@ const SOURCE_FLAGS = `Source flags (view/tui/validate/render): --token TOKEN bearer token (else ~/.omnigraph/credentials or env) --branch NAME read/write branch --profile NAME operator-config profile (else $OMNIGRAPH_PROFILE) + --allow-raw-gq enable the raw .gq escape hatch (off by default) With ~/.omnigraph operator config set up (\`omnigraph login\`), no flags needed.`; const HELP = `@modernrelay/notebook v${VERSION} — run an omnigraph notebook anywhere diff --git a/packages/cli/src/commands/view.ts b/packages/cli/src/commands/view.ts index 0c56fa4..03d6ca7 100644 --- a/packages/cli/src/commands/view.ts +++ b/packages/cli/src/commands/view.ts @@ -41,13 +41,15 @@ export async function viewCommand(argv: string[]): Promise { } const loaded = loadNotebook(notebookPath); - const connection = resolveConnection(loaded, sourceOptionsFrom(values)); + const sourceOpts = sourceOptionsFrom(values); + const connection = resolveConnection(loaded, sourceOpts); await serve({ notebookPath: loaded.notebookPath, connection, port, open: values["no-open"] !== true, + ...(sourceOpts.allowRawGq ? { allowRawGq: true } : {}), }); // The HTTP server keeps the event loop alive; hold here until Ctrl-C so the diff --git a/packages/cli/src/proxy.ts b/packages/cli/src/proxy.ts index 44b6102..1926a02 100644 --- a/packages/cli/src/proxy.ts +++ b/packages/cli/src/proxy.ts @@ -24,11 +24,14 @@ function stripHopByHop( /** * Reverse-proxy a request under `/og` to the upstream omnigraph-server: strip the - * `/og` prefix, set `Host` (changeOrigin, for TLS/vhost routing), and OVERWRITE - * `Authorization` with the server-side token (BFF — the token never reaches the - * browser). The body is piped unmodified, so the incoming `content-length` stays - * valid. omnigraph-server 0.7.0 sets no CORS headers, which is why the browser - * must talk to it same-origin through here. + * `/og` prefix, set `Host` (changeOrigin, for TLS/vhost routing), and inject + * `Authorization` from the server-side token. The proxy is **authoritative for + * auth** (BFF): it always drops any client-supplied `Authorization` / + * `Proxy-Authorization` and sets the bearer only from the server token, so a + * browser can never reach upstream with a credential of its own. The body is + * piped unmodified, so the incoming `content-length` stays valid. + * omnigraph-server 0.7.0 sets no CORS headers, which is why the browser must + * talk to it same-origin through here. */ export function proxyOg( req: http.IncomingMessage, @@ -42,8 +45,13 @@ export function proxyOg( const headers: http.OutgoingHttpHeaders = { ...req.headers }; headers.host = upstream.host; + // Never forward a client-supplied credential. proxy-authorization is also + // hop-by-hop (stripped below); authorization is end-to-end, so drop it here + // explicitly, then set only the server-side token. + delete headers.authorization; + delete headers["proxy-authorization"]; if (token) headers.authorization = `Bearer ${token}`; - // authorization is end-to-end and was just set above, so it survives the strip. + // authorization was just set from the server token, so it survives the strip. stripHopByHop(headers); const upstreamReq = lib.request( diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index f55ef01..a71360a 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -42,6 +42,8 @@ export interface ServeOptions { connection: Connection; port: number; open: boolean; + /** `--allow-raw-gq` — forwarded to the browser as `?allowRawGq=1`. */ + allowRawGq?: boolean; } export async function serve(opts: ServeOptions): Promise { @@ -98,7 +100,7 @@ export async function serve(opts: ServeOptions): Promise { }); }); const port = (server.address() as AddressInfo).port; - const url = buildOpenUrl(port, opts.connection); + const url = buildOpenUrl(port, opts.connection, opts.allowRawGq === true); process.stdout.write(`\n@modernrelay/notebook → http://127.0.0.1:${port}\n`); process.stdout.write(` ${opts.connection.label}\n`); @@ -106,12 +108,18 @@ export async function serve(opts: ServeOptions): Promise { if (opts.open) openBrowser(url); } -function buildOpenUrl(port: number, conn: Connection): string { +function buildOpenUrl( + port: number, + conn: Connection, + allowRawGq: boolean, +): string { const params = new URLSearchParams(); params.set("notebook", "/notebook.yaml"); params.set("server", "/og"); // same-origin via the proxy above params.set("graph", conn.graphId); if (conn.branch) params.set("branch", conn.branch); + // The browser gates rawGq itself (server-side flag → URL → web config). + if (allowRawGq) params.set("allowRawGq", "1"); return `http://127.0.0.1:${port}/?${params.toString()}`; } diff --git a/packages/cli/src/source.ts b/packages/cli/src/source.ts index 691f4fa..821f369 100644 --- a/packages/cli/src/source.ts +++ b/packages/cli/src/source.ts @@ -14,6 +14,8 @@ export interface SourceOptions { graph?: string; /** `--profile` — named operator-config profile. */ profile?: string; + /** `--allow-raw-gq` — enable the raw `.gq` escape hatch (off by default). */ + allowRawGq?: boolean; } export interface LoadedNotebook { @@ -96,6 +98,7 @@ export function buildSource( }); const source = new ServerSource(client, { ...(connection.branch !== undefined ? { branch: connection.branch } : {}), + ...(opts.allowRawGq ? { allowRawGq: true } : {}), }); return { source, connection }; } diff --git a/packages/client/src/http.ts b/packages/client/src/http.ts index 8aff707..ba298da 100644 --- a/packages/client/src/http.ts +++ b/packages/client/src/http.ts @@ -21,7 +21,12 @@ import { export interface ClientOptions { baseUrl: string; - /** Bearer token. Falls back to `OMNIGRAPH_TOKEN` env var when unset. */ + /** + * Bearer token, supplied explicitly by the caller. Token resolution (flags, + * `~/.omnigraph/credentials`, the `OMNIGRAPH_TOKEN_` / + * `OMNIGRAPH_BEARER_TOKEN` chain) lives in the shared operator resolver + * (`@modernrelay/notebook-client/node`); the Client reads no env itself. + */ token?: string; /** * Cluster graph id. omnigraph-server 0.7.0+ is cluster-only: every read and @@ -84,10 +89,9 @@ export class Client { private readonly graphId: string | undefined; constructor(opts: ClientOptions) { - const token = - opts.token ?? - process.env.OMNIGRAPH_TOKEN ?? - process.env.OMNIGRAPH_BEARER_TOKEN; + // No env fallback here — the caller passes an already-resolved token (see + // the operator resolver). Keeps token resolution in one place (canon §4.7). + const token = opts.token; this.graphId = opts.graphId; this.og = new Omnigraph({ baseUrl: opts.baseUrl, diff --git a/packages/client/src/source.test.ts b/packages/client/src/source.test.ts index 7ca8941..393d768 100644 --- a/packages/client/src/source.test.ts +++ b/packages/client/src/source.test.ts @@ -10,11 +10,11 @@ const CTX: ExecutionContext = { }; describe("ServerSource", () => { - it("declares runtime capabilities", () => { + it("declares runtime capabilities; rawGq is off by default", () => { const source = new ServerSource(fakeClient({})); expect(source.capabilities()).toMatchObject({ namedQueries: true, - rawGq: true, + rawGq: false, mutationKinds: ["set_field"], branchReads: true, snapshotReads: true, @@ -22,6 +22,12 @@ describe("ServerSource", () => { }); }); + it("advertises rawGq only when the escape hatch is enabled", () => { + expect( + new ServerSource(fakeClient({}), { allowRawGq: true }).capabilities().rawGq, + ).toBe(true); + }); + it("invokes a catalog query by ref with params + target", async () => { const invoke = vi.fn(async () => readOutput([{ id: "d1" }])); const source = new ServerSource(fakeClient({ invoke }), { branch: "main" }); diff --git a/packages/client/src/source.ts b/packages/client/src/source.ts index 41ee2d9..bbb559e 100644 --- a/packages/client/src/source.ts +++ b/packages/client/src/source.ts @@ -22,6 +22,13 @@ import { translateMutation } from "./translate.js"; export interface ServerSourceOptions { /** Default branch for reads + writes. CLI flag and notebook field win over this. */ branch?: string; + /** + * Allow the raw `.gq` escape hatch (`query.rawGq`). **Off by default** — in + * production/operator contexts notebooks bind to server-owned catalog queries + * (`query.ref`); raw `.gq` is a deliberate dev/CLI opt-in (canon §4.2). When + * false, a notebook with a `rawGq` cell fails compatibility validation. + */ + allowRawGq?: boolean; } export class ServerSource implements Source { @@ -33,7 +40,9 @@ export class ServerSource implements Source { capabilities(): SourceCapabilities { return { namedQueries: true, - rawGq: true, + // Capability-gated, off by default (canon §4.2): only advertised when the + // dev/CLI escape hatch is explicitly enabled. + rawGq: this.opts.allowRawGq ?? false, mutationKinds: ["set_field"], branchReads: true, snapshotReads: true, diff --git a/packages/core/src/runtime/compatibility.ts b/packages/core/src/runtime/compatibility.ts index d598052..15d852a 100644 --- a/packages/core/src/runtime/compatibility.ts +++ b/packages/core/src/runtime/compatibility.ts @@ -26,11 +26,16 @@ export function validateNotebookCompatibility( ); } if (cell.query?.rawGq !== undefined) { - warnings.push( - `${cell.id}: query.rawGq is a capability-gated escape hatch; prefer a catalog query.ref`, - ); if (!capabilities.rawGq) { - errors.push(`${cell.id}: selected source does not support raw .gq`); + // Off by default in production/operator contexts — fatal unless the + // explicit dev/CLI escape hatch is enabled. + errors.push( + `${cell.id}: raw .gq is disabled — enable the dev/CLI escape hatch (--allow-raw-gq or ?allowRawGq) or use a catalog query.ref`, + ); + } else { + warnings.push( + `${cell.id}: query.rawGq is a capability-gated escape hatch; prefer a catalog query.ref`, + ); } } if (cell.query?.branch !== undefined && !capabilities.branchReads) { diff --git a/packages/tui/src/index.tsx b/packages/tui/src/index.tsx index c05af83..847717a 100644 --- a/packages/tui/src/index.tsx +++ b/packages/tui/src/index.tsx @@ -36,6 +36,7 @@ interface ParsedArgs { branch?: string; graph?: string; profile?: string; + allowRawGq?: boolean; } function parseArgs(argv: readonly string[]): ParsedArgs { @@ -52,6 +53,8 @@ function parseArgs(argv: readonly string[]): ParsedArgs { out.graph = argv[++i]; } else if (a === "--profile") { out.profile = argv[++i]; + } else if (a === "--allow-raw-gq") { + out.allowRawGq = true; } else if (a === "-h" || a === "--help") { printUsage(); process.exit(0); @@ -68,7 +71,7 @@ function parseArgs(argv: readonly string[]): ParsedArgs { function printUsage(): void { process.stderr.write(` -omnigraph-tui [--server NAME|URL] [--graph ID] [--token TOKEN] [--branch NAME] [--profile NAME] +omnigraph-tui [--server NAME|URL] [--graph ID] [--token TOKEN] [--branch NAME] [--profile NAME] [--allow-raw-gq] Reads + writes go to omnigraph-server via the @modernrelay/omnigraph SDK. Connection resolves from flags, then omnigraph operator config @@ -112,10 +115,10 @@ export function main(argv: readonly string[]): void { graphId: conn.graphId, ...(conn.token !== undefined ? { token: conn.token } : {}), }); - const source: Source = new ServerSource( - client, - conn.branch !== undefined ? { branch: conn.branch } : {}, - ); + const source: Source = new ServerSource(client, { + ...(conn.branch !== undefined ? { branch: conn.branch } : {}), + ...(args.allowRawGq ? { allowRawGq: true } : {}), + }); const label = conn.label; render( diff --git a/packages/web/src/App.test.ts b/packages/web/src/App.test.ts index dc0812b..7f4b423 100644 --- a/packages/web/src/App.test.ts +++ b/packages/web/src/App.test.ts @@ -12,10 +12,17 @@ describe("buildConfig", () => { ); const config = await buildConfig(); expect(config.label).toBe("server: http://example.test · graph: acme · review"); - expect(config.source.capabilities().rawGq).toBe(true); + // rawGq is off unless the explicit ?allowRawGq escape hatch is present. + expect(config.source.capabilities().rawGq).toBe(false); expect(storage.get("omnigraph_token")).toBe("tok"); }); + it("enables rawGq only via the ?allowRawGq escape hatch", async () => { + stubWindow("http://127.0.0.1:5173/?server=http://example.test&graph=acme&allowRawGq=1"); + const config = await buildConfig(); + expect(config.source.capabilities().rawGq).toBe(true); + }); + it("loads a notebook from the ?notebook= URL", async () => { stubWindow("http://127.0.0.1:5173/?notebook=/dash/notebook.yaml&graph=acme"); const fetch = vi.fn(async (input: URL | string) => { diff --git a/packages/web/src/config.ts b/packages/web/src/config.ts index 66098da..be7c837 100644 --- a/packages/web/src/config.ts +++ b/packages/web/src/config.ts @@ -11,13 +11,17 @@ export interface AppConfig { } function readToken(): string | undefined { + // Only an explicit `?token=` (persisted for direct, non-proxy server mode). + // No default token: through the `view` BFF the proxy injects the server-side + // token and strips any client-supplied Authorization, so the browser holds + // none of its own (canon §4.7). const url = new URL(window.location.href); const fromUrl = url.searchParams.get("token"); if (fromUrl) { window.localStorage.setItem("omnigraph_token", fromUrl); return fromUrl; } - return window.localStorage.getItem("omnigraph_token") ?? "devtoken"; + return window.localStorage.getItem("omnigraph_token") ?? undefined; } export async function buildConfig(): Promise { @@ -46,6 +50,9 @@ export async function buildConfig(): Promise { ); } const branch = url.searchParams.get("branch") ?? undefined; + // rawGq is off by default (operator/production context); enable only via the + // explicit `?allowRawGq` escape hatch (e.g. `view --allow-raw-gq` forwards it). + const allowRawGq = isTruthyParam(url.searchParams.get("allowRawGq")); // omnigraph-server 0.7.0+ is cluster-only; reads/writes are graph-scoped. const graph = url.searchParams.get("graph") ?? notebook.graph; if (!graph) { @@ -60,11 +67,19 @@ export async function buildConfig(): Promise { }); return { notebook, - source: new ServerSource(client, branch ? { branch } : {}), + source: new ServerSource(client, { + ...(branch ? { branch } : {}), + ...(allowRawGq ? { allowRawGq: true } : {}), + }), label: `server: ${server} · graph: ${graph}${branch ? ` · ${branch}` : ""}`, }; } +/** URL flag truthiness: present and not an explicit off value → true. */ +function isTruthyParam(v: string | null): boolean { + return v !== null && v !== "" && v !== "0" && v !== "false"; +} + async function fetchText(url: URL): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`${url.toString()} returned ${res.status}`); From 775b591950180e3479398b47016da61cab93d3fa Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 24 Jun 2026 22:05:41 +0300 Subject: [PATCH 9/9] Interactive canvas: drag/resize tiles, retire overlay, layout polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolves the web host into a single canvas of dependent cards: - Tier-1 arrange: drag-reorder (@dnd-kit) + drag-edge resize (1–6 cols), browser-local overrides in localStorage (layout-overrides.ts); @formkit/ auto-animate eases cards into place; min-w-0 so content reflows without blowing out the grid; rAF-coalesced resize. - Retire the overlay tier: remove cell `display`/`open_state` + the drawer primitive; master-detail is now in-flow dependent cards reacting to $state. - Layout: drop the redundant cell-list sidebar, widen the canvas (max-w-[96rem]), sticky header, less-rounded cards. - view server: bare URL redirects to the served notebook (no more bundled-default cross-origin CORS failure). Spec/runtime/TUI essentially unchanged (TUI ignores layout); web-host only. Co-Authored-By: Claude Opus 4.8 (1M context) --- dash-books-canon.md | 32 +- packages/cli/src/serve.ts | 31 +- packages/core/src/spec/index.test.ts | 21 +- packages/core/src/spec/index.ts | 15 +- packages/tui/src/App.tsx | 4 +- packages/web/package.json | 6 +- packages/web/src/App.tsx | 402 +++++++++++++++------- packages/web/src/components/ui/card.tsx | 2 +- packages/web/src/components/ui/drawer.tsx | 76 ---- packages/web/src/layout-overrides.test.ts | 105 ++++++ packages/web/src/layout-overrides.ts | Bin 0 -> 4857 bytes packages/web/src/layout.test.ts | 77 +---- packages/web/src/layout.ts | 66 +--- pnpm-lock.yaml | 67 +++- 14 files changed, 512 insertions(+), 392 deletions(-) delete mode 100644 packages/web/src/components/ui/drawer.tsx create mode 100644 packages/web/src/layout-overrides.test.ts create mode 100644 packages/web/src/layout-overrides.ts diff --git a/dash-books-canon.md b/dash-books-canon.md index b60f5e0..f666b67 100644 --- a/dash-books-canon.md +++ b/dash-books-canon.md @@ -283,22 +283,28 @@ the auto tier and output-binding validation; until it ships, v1 uses author-decl - [ ] Typed result envelopes (`rows` / `graph` / `tree`) with schema-derived metadata (pending the decision above). - [ ] Auto-render tier from result metadata; explicit `lens:` overrides. - [~] Curated, tested web-first component/layout catalog; TUI best-fit/degraded over the same contract. - - [x] **2A — layout tier, first primitive.** Cell `display: inline|drawer|modal` + `open_state` - JSON-pointer: cells sharing a pointer lift into one overlay, open while that pointer is truthy - (the same `/selected` a Table writes on row click), close clears it. Host-shell only — no - json-render/spec/runtime change; reuses the selection state + `applyStateChanges`. Web renders - the drawer/modal (`web/src/layout.ts` `partitionCells` + `components/ui/drawer.tsx`); the TUI is - layout-flat and renders every cell inline. Lenses (Timeline, Card, wrap, click-to-select) landed - alongside. - - [x] **2D (first slice) — in-flow layout grid.** Cell `width: full|half|third|two-thirds` sets the - cell's span in the web host's responsive 6-column inline grid (`web/src/layout.ts` - `widthToColSpan` → literal `md:col-span-*`; `App.tsx` inline stack → `grid md:grid-cols-6`). - Default `full` = the old single-column stack; halves/thirds sit side-by-side (two-pane - master-detail, KPI rows). Complements 2A's overlay tier; host-shell only, TUI ignores it (one - cell per tab). Collapses to one column below `md`. + - [x] **Canvas of dependent cards (the layout model).** Every cell is a tile on the web host's + responsive 6-column grid; **master-detail is expressed by `$state` dependency** — a Table writes + `/selected`, dependent cells whose queries read `{ $state: "/selected" }` re-resolve **in place** + (the runtime's `dependencyMap` tracks the `$state` edges and re-runs only those cells). This is + the coherent center the parked dependency-DAG item pointed at. (An overlay tier — cell + `display: drawer|modal` + `open_state`, a `partitionCells`/drawer presentation — was built first, + then **removed** as paradigm-breaking: it yanked dependent cards off the canvas into a floating + modal. Recoverable from git if ever wanted.) + - [x] **In-flow layout grid (`width`).** Cell `width: full|half|third|two-thirds` sets the tile's span + in the canvas grid (`web/src/layout.ts` `widthToColSpan` → literal `md:col-span-*`; `App.tsx` → + `grid md:grid-cols-6`). Default `full` = its own row; halves/thirds sit side-by-side (two-pane + master-detail, KPI rows). Host-shell only, TUI ignores it (one cell per tab); collapses to one + column below `md`. - [x] **Quote lens.** Renders rows as a blockquote feed — `text_column` + a `source_column · meta…` citation (`refs/r2.jpg`) — for highlights/annotations/comments. Utterance-centric, distinct from Timeline (event feed). Replaces the cramped 2-column highlights table; web + Ink renderers. + - [x] **Interactive arrange (Tier 1).** An "Edit layout" toggle lets you drag-reorder cells (a handle; + `@dnd-kit` sortable) and drag a cell's right edge to resize its column span (1–6; raw pointer + events). It's a **browser-local override** of the declared order/`width`, persisted to + `localStorage` per notebook (`web/src/layout-overrides.ts` — pure `applyOverrides`/`effectiveColSpan` + + a `notebookKey`); the YAML stays the source of truth and **Reset** clears it. Width-axis only + (height fights content); no YAML write-back (that's a deferred Tier 3); web-only, TUI unaffected. - [ ] Extend the catalog by in-tree, reviewed contribution (no third-party/sandboxed lenses); TUI renderer or table fallback per component. - [ ] Explicit dependency DAG (inputs, controls, query params, reads, cells) — no client expression layer. diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index a71360a..5bbc3aa 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -63,6 +63,15 @@ export async function serve(opts: ServeOptions): Promise { return; } + // 0. Bare host (no query string) → redirect to the parametrized URL so the + // *served* notebook loads over the /og proxy, not the bundled default + // (which would talk to the upstream cross-origin and CORS-fail). + if (path === "/" && !(req.url ?? "").includes("?")) { + const params = notebookParams(opts.connection, opts.allowRawGq === true); + res.writeHead(302, { location: `/?${params.toString()}` }); + res.end(); + return; + } // 1. /og/* → BFF reverse proxy (token injected server-side). if (path === "/og" || path.startsWith("/og/")) { proxyOg(req, res, upstream, opts.connection.token); @@ -108,11 +117,13 @@ export async function serve(opts: ServeOptions): Promise { if (opts.open) openBrowser(url); } -function buildOpenUrl( - port: number, - conn: Connection, - allowRawGq: boolean, -): string { +/** + * Query that points the SPA at the served notebook over the same-origin proxy. + * Without these params the app loads its bundled default notebook and talks to + * the upstream cross-origin (CORS-blocked), so both the printed URL and the + * bare-host redirect carry them. + */ +function notebookParams(conn: Connection, allowRawGq: boolean): URLSearchParams { const params = new URLSearchParams(); params.set("notebook", "/notebook.yaml"); params.set("server", "/og"); // same-origin via the proxy above @@ -120,7 +131,15 @@ function buildOpenUrl( if (conn.branch) params.set("branch", conn.branch); // The browser gates rawGq itself (server-side flag → URL → web config). if (allowRawGq) params.set("allowRawGq", "1"); - return `http://127.0.0.1:${port}/?${params.toString()}`; + return params; +} + +function buildOpenUrl( + port: number, + conn: Connection, + allowRawGq: boolean, +): string { + return `http://127.0.0.1:${port}/?${notebookParams(conn, allowRawGq).toString()}`; } function sendFile(res: http.ServerResponse, file: string, ext: string): void { diff --git a/packages/core/src/spec/index.test.ts b/packages/core/src/spec/index.test.ts index a41edb1..aa4a026 100644 --- a/packages/core/src/spec/index.test.ts +++ b/packages/core/src/spec/index.test.ts @@ -139,24 +139,7 @@ cells: expect(nb.cells[1]?.on?.["press"]?.action).toBe("approve"); }); - it("parses a cell's layout-tier display + open_state", () => { - const yaml = ` -version: 1 -title: Detail drawer -cells: - - id: detail - lens: Card - query: { ref: get_concept } - display: drawer - open_state: /selected - props: { title_column: c.name } -`; - const nb = parseNotebook(yaml); - expect(nb.cells[0]?.display).toBe("drawer"); - expect(nb.cells[0]?.open_state).toBe("/selected"); - }); - - it("rejects an unknown display mode (enum)", () => { + it("rejects a removed overlay field (display, strict schema)", () => { expect(() => parseNotebook(` version: 1 @@ -165,7 +148,7 @@ cells: - id: t lens: Card query: { ref: q } - display: popover + display: drawer `), ).toThrow(); }); diff --git a/packages/core/src/spec/index.ts b/packages/core/src/spec/index.ts index 7376430..899567a 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -152,22 +152,9 @@ export const CellSchema = z * Accepts a boolean, a state-condition object, or an array (AND). */ visible: z.unknown().optional(), - /** - * Presentation mode (host-shell layout tier, web-first). `inline` (default) - * stacks the cell in flow; `drawer`/`modal` render it in an overlay that is - * open while `open_state` is truthy. The TUI ignores this and renders inline. - */ - display: z.enum(["inline", "drawer", "modal"]).optional(), - /** - * JSON-pointer whose truthy value opens this cell's overlay (with `display: - * drawer|modal`). Cells sharing an `open_state` render in one overlay; the - * close affordance clears this pointer. Typically the selection pointer a - * Table writes via `select_state` (e.g. "/selected"). - */ - open_state: z.string().optional(), /** * In-flow layout width (host-shell layout tier, web-first). The web host - * arranges inline cells in a responsive 6-column grid; this sets the cell's + * arranges cells in a responsive 6-column canvas grid; this sets the cell's * column span — `full` (default, own row), `two-thirds`, `half`, `third`. * Cells flow left-to-right and wrap. The TUI ignores this (one cell per tab). */ diff --git a/packages/tui/src/App.tsx b/packages/tui/src/App.tsx index 4f1edee..0ab23c7 100644 --- a/packages/tui/src/App.tsx +++ b/packages/tui/src/App.tsx @@ -134,8 +134,8 @@ export function App({ {/* Tab strip: all cells, active highlighted. The TUI is layout-flat: - it ignores a cell's `display: drawer|modal` and `width` (web-only - layout tiers) and renders every cell as a full inline tab. */} + it ignores a cell's `width` (a web-only layout tier) and renders + every cell as a full inline tab. */} {cellCount > 0 && ( {cells.map((c, i) => ( diff --git a/packages/web/package.json b/packages/web/package.json index 67566da..24b248a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,12 +13,16 @@ }, "dependencies": { "@base-ui/react": "^1.5.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist-mono": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", + "@formkit/auto-animate": "^0.9.0", "@json-render/core": "^0.19.0", "@json-render/react": "^0.19.0", - "@modernrelay/notebook-core": "workspace:*", "@modernrelay/notebook-client": "workspace:*", + "@modernrelay/notebook-core": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.20.0", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index fb74e51..6f88ac4 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,4 +1,22 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + DndContext, + closestCenter, + PointerSensor, + KeyboardSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; import { JSONUIProvider, Renderer } from "@json-render/react"; import { createNotebookRuntime, @@ -30,15 +48,26 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { Overlay } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; -import { SearchIcon } from "lucide-react"; +import { GripVerticalIcon, SearchIcon } from "lucide-react"; import { CommandPalette, type CommandSection, } from "./components/CommandPalette.js"; import { useHotkeys, type Hotkey } from "./lib/hotkeys.js"; -import { partitionCells, readPointer, widthToColSpan } from "./layout.js"; +import { widthToColSpan } from "./layout.js"; +import { + applyOverrides, + clearOverrides, + effectiveColSpan, + loadOverrides, + notebookKey, + saveOverrides, + spanToColSpan, + withOrder, + withSpan, + type LayoutOverrides, +} from "./layout-overrides.js"; type ConfigStatus = | { kind: "loading" } @@ -101,6 +130,33 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { >(null); const [cmdOpen, setCmdOpen] = useState(false); + // Layout edit mode: browser-local drag-reorder + width-resize, persisted to + // localStorage. An override layer over the declared layout; Reset clears it. + const [editing, setEditing] = useState(false); + const layoutKey = useMemo(() => notebookKey(config.notebook), [config.notebook]); + const [overrides, setOverrides] = useState(() => + loadOverrides(layoutKey), + ); + const updateOverrides = useCallback( + (next: LayoutOverrides) => { + setOverrides(next); + saveOverrides(layoutKey, next); + }, + [layoutKey], + ); + const resetLayout = useCallback(() => { + setOverrides({ order: [], spans: {} }); + clearOverrides(layoutKey); + }, [layoutKey]); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + // Animate the canvas grid: cards FLIP into place on resize / post-reorder + // settle / dependent-card re-resolve. Disabled during an active dnd drag so it + // doesn't fight dnd-kit's own transforms (dnd-kit owns the drag). + const [gridParent, enableGridAnim] = useAutoAnimate(); + const handleStateChange = useCallback( (changes: Array<{ path: string; value: unknown }>) => { runtime.applyStateChanges(changes); @@ -188,9 +244,8 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { handlers={handlers} > } header={ -
+

{config.notebook.title} @@ -203,6 +258,27 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement {

+ {editing && ( + + )} + + )} + {humanizeCellId(cell.cell.id)} + + {cell.pending ? ( - - {cell.result.row_count} row - {cell.result.row_count === 1 ? "" : "s"} - {" · "} - {cell.durationMs}ms - + - ) - )} - - - - - + ) : ( + cell.error === null && + cell.result !== null && ( + + + {cell.result.row_count} row + {cell.result.row_count === 1 ? "" : "s"} + {" · "} + {cell.durationMs}ms + + + ) + )} + + + + + + {editing && onResize && ( + { + setPreviewSpan(null); + onResize(s); + }} + /> + )} +
+ ); +} + +/** + * Right-edge width-resize handle. Raw pointer events (no resize lib): maps the + * pointer's x, relative to the cell's left, onto the 6-column grid and snaps to + * a 1–6 span — live-previewing during the drag, committing on release. Sits + * inside a `data-cell-root` wrapper whose parent is the grid. + */ +function ResizeHandle({ + onPreview, + onCommit, +}: { + onPreview: (span: number) => void; + onCommit: (span: number) => void; +}): React.ReactElement { + const last = useRef(6); + const spanFor = (clientX: number, handle: HTMLElement): number => { + const root = handle.closest("[data-cell-root]") as HTMLElement | null; + const grid = root?.parentElement; + if (!root || !grid) return last.current; + const gap = parseFloat(getComputedStyle(grid).columnGap || "0") || 0; + const cols = 6; + const colW = (grid.clientWidth - gap * (cols - 1)) / cols; + const widthPx = clientX - root.getBoundingClientRect().left; + const span = Math.max( + 1, + Math.min(cols, Math.round((widthPx + gap) / (colW + gap))), + ); + last.current = span; + return span; + }; + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + const handle = e.currentTarget; + handle.setPointerCapture(e.pointerId); + // rAF-coalesce: at most one preview update per frame for a fluid resize. + let raf = 0; + let pendingX = e.clientX; + const move = (ev: PointerEvent): void => { + pendingX = ev.clientX; + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + onPreview(spanFor(pendingX, handle)); + }); + }; + const up = (ev: PointerEvent): void => { + if (raf) cancelAnimationFrame(raf); + handle.releasePointerCapture(e.pointerId); + handle.removeEventListener("pointermove", move); + handle.removeEventListener("pointerup", up); + onCommit(spanFor(ev.clientX, handle)); + }; + handle.addEventListener("pointermove", move); + handle.addEventListener("pointerup", up); + }} + > +
+
); } /** * A cell's interior — inline filter controls + the lens output, with the - * pending/error/skeleton states. Shared by the inline `CellCard` and the - * overlay drawer so both render identically. + * pending/error/skeleton states. Rendered inside `CellCard`. * * Re-querying (filter change / mutation re-run) keeps the previous spec * visible; we dim the lens output and show an "updating…" cue while the inline diff --git a/packages/web/src/components/ui/card.tsx b/packages/web/src/components/ui/card.tsx index 7bc982a..8e1ee34 100644 --- a/packages/web/src/components/ui/card.tsx +++ b/packages/web/src/components/ui/card.tsx @@ -10,7 +10,7 @@ export function Card({ }: useRender.ComponentProps<"div">): React.ReactElement { const defaultProps = { className: cn( - "relative flex flex-col rounded-2xl border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", + "relative flex flex-col rounded-lg border bg-card not-dark:bg-clip-padding text-card-foreground shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/6%)]", className, ), "data-slot": "card", diff --git a/packages/web/src/components/ui/drawer.tsx b/packages/web/src/components/ui/drawer.tsx deleted file mode 100644 index b05f9b9..0000000 --- a/packages/web/src/components/ui/drawer.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Dialog } from "@base-ui/react/dialog"; -import { XIcon } from "lucide-react"; -import type * as React from "react"; -import { cn } from "@/lib/utils"; - -/** - * Overlay primitive for the notebook layout tier (cell `display: drawer|modal`). - * - * `drawer` slides a right-anchored panel in from the edge; `modal` fades a - * centered card. Both wrap base-ui `Dialog` and are fully controlled: the host - * shell drives `open` from notebook state (an `open_state` JSON-pointer) and - * clears that pointer in `onClose`. We pass `modal="trap-focus"` so the - * underlying cell stack stays scrollable behind the drawer. - */ -export function Overlay({ - open, - onClose, - title, - variant = "drawer", - children, -}: { - open: boolean; - onClose: () => void; - title: React.ReactNode; - variant?: "drawer" | "modal"; - children: React.ReactNode; -}): React.ReactElement { - return ( - { - if (!next) onClose(); - }} - modal="trap-focus" - > - - - -
- - {title} - - - - -
-
{children}
-
-
-
- ); -} diff --git a/packages/web/src/layout-overrides.test.ts b/packages/web/src/layout-overrides.test.ts new file mode 100644 index 0000000..7d395ec --- /dev/null +++ b/packages/web/src/layout-overrides.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import type { CellExecution, Notebook } from "@modernrelay/notebook-core"; +import { + applyOverrides, + effectiveColSpan, + spanToColSpan, + clampSpan, + notebookKey, + withOrder, + withSpan, + type LayoutOverrides, +} from "./layout-overrides.js"; + +const cell = (id: string): CellExecution => + ({ cell: { id, lens: "Card" } }) as CellExecution; +const ids = (cs: CellExecution[]): string[] => cs.map((c) => c.cell.id); +const O = (o: Partial): LayoutOverrides => ({ + order: [], + spans: {}, + ...o, +}); + +describe("applyOverrides", () => { + const inline = [cell("a"), cell("b"), cell("c")]; + + it("is a no-op with no saved order", () => { + expect(ids(applyOverrides(inline, O({})))).toEqual(["a", "b", "c"]); + }); + + it("reorders by saved order, appending unranked cells in natural order", () => { + expect(ids(applyOverrides(inline, O({ order: ["c", "a"] })))).toEqual([ + "c", + "a", + "b", + ]); + }); + + it("drops saved ids no longer present (reconcile by id)", () => { + expect(ids(applyOverrides(inline, O({ order: ["x", "b", "a"] })))).toEqual([ + "b", + "a", + "c", + ]); + }); + + it("places a brand-new (unsaved) cell after the ordered ones", () => { + const withNew = [cell("a"), cell("b"), cell("new")]; + expect(ids(applyOverrides(withNew, O({ order: ["b", "a"] })))).toEqual([ + "b", + "a", + "new", + ]); + }); +}); + +describe("span helpers", () => { + it("clamps and rounds to 1–6", () => { + expect(clampSpan(0)).toBe(1); + expect(clampSpan(9)).toBe(6); + expect(clampSpan(3.4)).toBe(3); + }); + + it("maps spans to literal col-span classes", () => { + expect(spanToColSpan(1)).toBe("md:col-span-1"); + expect(spanToColSpan(4)).toBe("md:col-span-4"); + expect(spanToColSpan(99)).toBe("md:col-span-6"); + }); + + it("effectiveColSpan: override wins, else declared width", () => { + expect(effectiveColSpan({ id: "a" } as never, O({ spans: { a: 2 } }))).toBe( + "md:col-span-2", + ); + // no override → declared width mapping (half → col-span-3) + expect( + effectiveColSpan({ id: "a", width: "half" } as never, O({})), + ).toBe("md:col-span-3"); + // no override, no width → full row + expect(effectiveColSpan({ id: "a" } as never, O({}))).toBe("md:col-span-6"); + }); +}); + +describe("notebookKey", () => { + const nb = (title: string, cellIds: string[]): Notebook => + ({ version: 1, title, cells: cellIds.map((id) => ({ id })) }) as Notebook; + + it("is stable for the same title + cell-id set (order-independent)", () => { + expect(notebookKey(nb("X", ["a", "b"]))).toBe(notebookKey(nb("X", ["b", "a"]))); + }); + + it("changes when the cell-id set changes", () => { + expect(notebookKey(nb("X", ["a", "b"]))).not.toBe( + notebookKey(nb("X", ["a", "c"])), + ); + }); +}); + +describe("immutable updaters", () => { + it("withOrder / withSpan don't mutate the source", () => { + const base = O({ order: ["a"], spans: { a: 2 } }); + expect(withOrder(base, ["b", "a"]).order).toEqual(["b", "a"]); + expect(withSpan(base, "b", 9).spans).toEqual({ a: 2, b: 6 }); + expect(base.order).toEqual(["a"]); // unchanged + expect(base.spans).toEqual({ a: 2 }); + }); +}); diff --git a/packages/web/src/layout-overrides.ts b/packages/web/src/layout-overrides.ts new file mode 100644 index 0000000000000000000000000000000000000000..da51ac69118c60a8db8f58f47db0931122efc175 GIT binary patch literal 4857 zcma)AZEoYp5zTL(qE;Bpk{*d(&xaAn9}~x!0JA$^7#T;QXL++8I%t`X9x=Bg47FYwe#b$SPb=9j^uVtn)TUJypW}23Cq4T^ef8X5ew62UT zy7a+TdSvY_t!P}@DRurdwV5u9Qs-)MP=vR=)Rww)yl1?@CEb~ZiG?}8(rAjL^*VIvE zsfruj;pw_0TTo?ZWXC*{Z8n~L?`S{MX|770?Kifg6h|dn;vn>lhT`lnPUtVCy6KhL zzql(+r5)QE%E0iLGXLJ0e`xZrNzn|Ri@nt5^|YX&!-?|Qzl^W%m)Jy?Xf|ihs&l+hX*-qRaZiOJhF%I_h zykBd3eXdv>e!jH6)?WL4EA z@FmIYk{Sa|3GYmPsE=cmrntGfJ`Ot}b3U`C$W5UsB$)Htr1y9Or`QIu_E2j;6*k}{ zu1}ve>Lfey`*f+Orz2gS9q+C7_EH41qBrk9U4H35I{GQ9Q{3*;)peJGwdIOd$6?`5 zZ@&Ee>GPYvzWqC$QioH>8SML7>dz1Rq1-6@I(kmu{`oIJ#pxGEeLyL-(;4yuyFPzo z1VNWG#G(oXMU3yB$q1N}6E*p#33hs$xd{1K$1iR^66egH?5jltGdOpZBnf6;Cl1sT z#R&@&uObu2bar+IbJ$>o-%+wiz9f0qy&B%__%DN zmTvVz{8MfD_Ng)z3g8)n`XGp=a!nAqXc0$*+cM(nRA8AedYKuIQB8Y9)R{7SH$Q*+D>^f-kzw^d(l=9)cz(u=71+;8cA zJB1-a!zar%&Q?#B&9X#}z3M+%HgIU)FmPNlRkJ8cV>&&f6v>a{)llLaYQFU;w<>E3 zK0<&SAixqO0rJ)NGS;{AAMZAh3Rm<@bk+yFH69=Qm6$auWo zQ=X|$%T-raf?ZG{l&T8&@`nj0^Rc3X1F8!#CdAKAYOYK!PtJEZA)A&Kn_k3*xxyYI zfYG}X0bR0>Fo2-UEm(^zrr>757r{f8*^6k9jv*zL5rDWJqQ9BFH7o&9+q zdAcp^UBP=Zs&@A!iEBuBB!CE*n|sXJ(N~>T9eVmyUe4(|AhD>Stis4gnUKs?UTYW4 z<3yvGFQTXrCJ4p~Z_M{X0q_?0ehzAkn`_gCnNw&4#ep$TcS9*7h%Qzq&|DWXCHJxm zIw8DgvIN1h>>HcXfrIg`nkZ-!{Y$NjR38gVJ7!=C%cDZy9n^(v6od5}LBYlvO~Gnd zbgB3OaWR|mHsE2eG6q7*ELsXnx#j)}d_ls2>;WP+zrhSQ6EBhfb@T$f6qr&U%0XZDwjTM3x zCe?PC=eE$OCCfjwWb3zOercGyxCM11)Wih!(1tV+4uBEpT*=340ujq{U%>&Y-n2k6 zG#)tGtc;eDU_8<)_H5~eNTj+n662oe){em~Ts3aFg+wahhUWjEkFo#!6V5Q&oI2V) zl_OLtDU})sflL5Oo<5~=v^~~F3X^+y+ibOKeLJ^zc+8y4?!v0{UWQq!!C-aahl9&v z=ePD0?ZpQRAytWi6Qjh4`4OZ>Yfk9BDFZ>jY_JNkmBigkT<;|yZE@2@uBOoD{<0#M zCAz_d55eSX6mBc)OF>Iw5ZBWgcd@AG2l)&ndhXbytM{s!Ao=&vVHbGf&k&hM;d5yb zd5{pJrd8g9Pv;+gzIb;&7_>6on`K$d*Mc)7m!Wakr_MC%!^YzV35CI$Oz+J7g!>i(Jdj0yL_nWf% z4dkxB%{Rm@ncp}91U1;M#&-h{wxCPCcQ@3)0xnP}5&#jL7CqQwA zJnmM*XyD$L3;&IUd_&+l4$7msgFYg{?TC09kWinl7bQI4u7o&DCYQ?)I+@8$bIk;= z<#M+j{uq*M`}-8}E|IZyJ-wYyYkyIxXBmV`xx0k!Of~t)-+V-m1r?6UZ<;h;UE_QY z6z*n~EeUry8y4|hbcdkh?FI+{3y2*XV$@{Keq2Z#lLl|nW`4*15@5(yamR { - it("keeps plain cells inline", () => { - const { inline, overlays } = partitionCells([cell("a"), cell("b")]); - expect(inline.map((c) => c.cell.id)).toEqual(["a", "b"]); - expect(overlays).toHaveLength(0); - }); - - it("groups cells sharing display+open_state into one overlay", () => { - const cells = [ - cell("list"), - cell("detail", "drawer", "/selected"), - cell("related", "drawer", "/selected"), - ]; - const { inline, overlays } = partitionCells(cells); - expect(inline.map((c) => c.cell.id)).toEqual(["list"]); - expect(overlays).toHaveLength(1); - expect(overlays[0]?.openState).toBe("/selected"); - expect(overlays[0]?.variant).toBe("drawer"); - expect(overlays[0]?.cells.map((c) => c.cell.id)).toEqual([ - "detail", - "related", - ]); - }); - - it("separates overlays by pointer and by variant", () => { - const { overlays } = partitionCells([ - cell("d1", "drawer", "/a"), - cell("d2", "drawer", "/b"), - cell("m1", "modal", "/a"), - ]); - expect(overlays).toHaveLength(3); - expect(overlays.map((o) => `${o.variant}:${o.openState}`)).toEqual([ - "drawer:/a", - "drawer:/b", - "modal:/a", - ]); - }); - - it("treats a non-inline cell with no open_state as inline (nothing opens it)", () => { - const { inline, overlays } = partitionCells([cell("x", "drawer")]); - expect(inline.map((c) => c.cell.id)).toEqual(["x"]); - expect(overlays).toHaveLength(0); - }); -}); - -describe("readPointer", () => { - it("reads a top-level pointer", () => { - expect(readPointer({ selected: "abc" }, "/selected")).toBe("abc"); - }); - - it("reads a nested pointer and unescapes tokens", () => { - expect(readPointer({ a: { "b/c": 1 } }, "/a/b~1c")).toBe(1); - }); - - it("returns undefined for a missing path", () => { - expect(readPointer({}, "/nope")).toBeUndefined(); - expect(readPointer({ a: 1 }, "/a/b")).toBeUndefined(); - }); - - it("returns the whole state for the empty pointer", () => { - const state = { x: 1 }; - expect(readPointer(state, "")).toBe(state); - }); -}); +import { widthToColSpan } from "./layout.js"; describe("widthToColSpan", () => { it("maps each width to its literal 6-col span class", () => { diff --git a/packages/web/src/layout.ts b/packages/web/src/layout.ts index f88dde5..262454d 100644 --- a/packages/web/src/layout.ts +++ b/packages/web/src/layout.ts @@ -1,16 +1,14 @@ -import type { Cell, CellExecution } from "@modernrelay/notebook-core"; +import type { Cell } from "@modernrelay/notebook-core"; /** - * Layout tier (web host shell). Cells default to an inline vertical stack; a - * cell with `display: drawer|modal` + an `open_state` pointer lifts into an - * overlay that is open while that pointer is truthy in runtime state, and a - * cell's `width` sets its span in the inline responsive grid. This module holds - * the pure partition/pointer/width logic so it stays unit-testable apart from - * the React shell. See dash-books-canon.md §4.4. + * Layout tier (web host shell). Cells are tiles on a responsive 6-column canvas + * grid; a cell's `width` sets its column span. This module holds the pure + * width→class mapping so it stays unit-testable apart from the React shell. + * Drag/resize overrides live in `layout-overrides.ts`. See dash-books-canon.md §4.4. */ /** - * Map a cell `width` to its Tailwind column span in the inline 6-column grid + * Map a cell `width` to its Tailwind column span in the canvas grid * (`md:grid-cols-6`). Returns **complete literal class strings** from a lookup * (never interpolated) so the Tailwind JIT scanner emits them. `full`/absent → * a whole row; `two-thirds`/`half`/`third` divide the 6 columns evenly. @@ -28,55 +26,3 @@ export function widthToColSpan(width: Cell["width"]): string { return "md:col-span-6"; } } - -/** An overlay (drawer/modal) is open while its `openState` pointer is truthy. */ -export interface OverlayGroup { - openState: string; - variant: "drawer" | "modal"; - cells: CellExecution[]; -} - -/** - * Split executed cells into the inline stack and overlay groups. A cell joins - * an overlay only when it declares both a non-inline `display` and an - * `open_state`; cells sharing a `display`+`open_state` collapse into one - * overlay (detail + related + … in a single drawer). Everything else — - * including a `drawer` cell with no `open_state`, which has nothing to open it - * — stays inline. Order is preserved within each bucket. - */ -export function partitionCells(cells: CellExecution[]): { - inline: CellExecution[]; - overlays: OverlayGroup[]; -} { - const inline: CellExecution[] = []; - const groups = new Map(); - for (const cell of cells) { - const display = cell.cell.display ?? "inline"; - const openState = cell.cell.open_state; - if (display === "inline" || !openState) { - inline.push(cell); - continue; - } - const key = `${display} ${openState}`; - const group = groups.get(key) ?? { openState, variant: display, cells: [] }; - group.cells.push(cell); - groups.set(key, group); - } - return { inline, overlays: [...groups.values()] }; -} - -/** Read a JSON-pointer (RFC 6901) value out of runtime state; undefined if absent. */ -export function readPointer( - state: Record, - pointer: string, -): unknown { - if (pointer === "") return state; - if (!pointer.startsWith("/")) return undefined; - let cur: unknown = state; - for (const raw of pointer.slice(1).split("/")) { - const key = raw.replace(/~1/g, "/").replace(/~0/g, "~"); - if (cur === null || typeof cur !== "object") return undefined; - cur = (cur as Record)[key]; - } - return cur; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7fc3b5..668fc40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,12 +129,24 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.5.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.6) '@fontsource-variable/geist-mono': specifier: ^5.2.8 version: 5.2.8 '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@formkit/auto-animate': + specifier: ^0.9.0 + version: 0.9.0 '@json-render/core': specifier: ^0.19.0 version: 0.19.0(zod@4.4.3) @@ -225,6 +237,28 @@ packages: '@types/react': optional: true + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -411,6 +445,9 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@formkit/auto-animate@0.9.0': + resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1566,6 +1603,31 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@dnd-kit/utilities': 3.2.2(react@19.2.6) + react: 19.2.6 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.6)': + dependencies: + react: 19.2.6 + tslib: 2.8.1 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1681,6 +1743,8 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} + '@formkit/auto-animate@0.9.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2468,8 +2532,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsup@8.5.1(jiti@2.7.0)(postcss@8.5.14)(typescript@6.0.3)(yaml@2.8.4): dependencies: