diff --git a/AGENTS.md b/AGENTS.md index 262a441..709ffc4 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, 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. | | `@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 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 ``. -### 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. 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}/…`. -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: `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 @@ -95,10 +92,10 @@ 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 -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 6c568d1..7da9689 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, 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. | | `@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 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 ``. -### 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. 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}/…`. -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: `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 @@ -95,10 +92,10 @@ 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 -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 099fc1d..dfe46ed 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) @@ -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 @@ -57,20 +59,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..f666b67 --- /dev/null +++ b/dash-books-canon.md @@ -0,0 +1,314 @@ +# 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`, `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 +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).** +- [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 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). +- [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 +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. + - [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. + +**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-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/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/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/cli/package.json b/packages/cli/package.json index 01c7796..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", @@ -33,7 +34,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/args.ts b/packages/cli/src/args.ts index e435306..b7705b6 100644 --- a/packages/cli/src/args.ts +++ b/packages/cli/src/args.ts @@ -4,12 +4,14 @@ 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` + `--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. */ @@ -21,5 +23,7 @@ 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; + 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 84e5f3b..bbc4820 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,10 +1,31 @@ +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) + --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 Usage: notebook [args] (npx @modernrelay/notebook …) @@ -16,11 +37,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 +87,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 +108,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 +119,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..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,12 +20,15 @@ const AUTHOR_PROPS: Record = { Path: PathAuthorPropsSchema, Subgraph: SubgraphAuthorPropsSchema, ActionList: ActionListAuthorPropsSchema, + Timeline: TimelineAuthorPropsSchema, + Card: CardAuthorPropsSchema, }; /** * 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 +45,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(); 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/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 061416b..5bbc3aa 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"; @@ -44,32 +42,13 @@ 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 { 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] ?? "/"; @@ -84,13 +63,17 @@ 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/")) { - 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 +82,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"); }); @@ -131,7 +109,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`); @@ -139,16 +117,29 @@ export async function serve(opts: ServeOptions): Promise { if (opts.open) openBrowser(url); } -function buildOpenUrl(port: number, conn: Connection): 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("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); - } - return `http://127.0.0.1:${port}/?${params.toString()}`; + 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 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/cli/src/source.ts b/packages/cli/src/source.ts index da258a8..821f369 100644 --- a/packages/cli/src/source.ts +++ b/packages/cli/src/source.ts @@ -1,17 +1,21 @@ 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 { 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; + /** `--allow-raw-gq` — enable the raw `.gq` escape hatch (off by default). */ + allowRawGq?: boolean; } export interface LoadedNotebook { @@ -27,59 +31,49 @@ 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 { - mode: "fixture" | "server"; - /** Upstream omnigraph-server URL (server mode). */ - server?: string; + /** Resolved omnigraph-server base 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 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; - if (notebook.fixture) { - return { - mode: "fixture", - label: `fixture: ${notebook.fixture}`, + 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 } : {}), - }; - } - const server = opts.server ?? notebook.server; - if (!server) { - throw new Error( - "notebook has neither `fixture:` nor `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", - ); - } + ...(opts.profile !== undefined ? { profile: opts.profile } : {}), + }, + { + ...(loaded.notebook.server !== undefined + ? { server: loaded.notebook.server } + : {}), + ...(loaded.notebook.graph !== undefined + ? { graph: loaded.notebook.graph } + : {}), + }, + ); return { - mode: "server", - 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 } : {}), }; } @@ -89,29 +83,22 @@ 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, { ...(connection.branch !== undefined ? { branch: connection.branch } : {}), + ...(opts.allowRawGq ? { allowRawGq: true } : {}), }); return { source, connection }; } 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/http.ts b/packages/client/src/http.ts index 74436fe..ba298da 100644 --- a/packages/client/src/http.ts +++ b/packages/client/src/http.ts @@ -16,11 +16,17 @@ import { OmnigraphError, type QueryInput as SdkQueryInput, type MutationInput as SdkMutationInput, + type Read as SdkRead, } from "@modernrelay/omnigraph"; 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 @@ -83,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, @@ -136,6 +141,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/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/client/src/source.test.ts b/packages/client/src/source.test.ts index 04ed17a..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({ - structuredQueryKinds: ["nodes", "path", "ego"], - rawGq: true, + namedQueries: true, + rawGq: false, mutationKinds: ["set_field"], branchReads: true, snapshotReads: true, @@ -22,181 +22,52 @@ 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("advertises rawGq only when the escape hatch is enabled", () => { + expect( + new ServerSource(fakeClient({}), { allowRawGq: true }).capabilities().rawGq, + ).toBe(true); }); - 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 +123,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 +136,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..bbb559e 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,19 +15,20 @@ 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. */ 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 { @@ -37,8 +39,10 @@ export class ServerSource implements Source { capabilities(): SourceCapabilities { return { - structuredQueryKinds: ["nodes", "path", "ego"], - rawGq: true, + namedQueries: 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, @@ -50,40 +54,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 +105,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 +115,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 2ba4079..0daba2c 100644 --- a/packages/client/src/translate.ts +++ b/packages/client/src/translate.ts @@ -1,26 +1,13 @@ /** - * 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. + * 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; @@ -35,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). */ @@ -286,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 { @@ -299,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/catalog/index.test.ts b/packages/core/src/catalog/index.test.ts index de8e9f1..381b193 100644 --- a/packages/core/src/catalog/index.test.ts +++ b/packages/core/src/catalog/index.test.ts @@ -17,14 +17,17 @@ const fakeResult: QueryResult = { }; describe("lensComponents", () => { - it("registers all seven components (4 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", + "Timeline", "Toggle", ]); }); @@ -52,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), @@ -59,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 c79491a..4264d9c 100644 --- a/packages/core/src/catalog/index.ts +++ b/packages/core/src/catalog/index.ts @@ -29,6 +29,27 @@ 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 { + QuoteAuthorPropsSchema, + QuoteRuntimePropsSchema, + QuoteDescription, + type QuoteAuthorProps, + type QuoteRuntimeProps, +} from "./lenses/quote.js"; import { ButtonRuntimePropsSchema, ButtonDescription, @@ -46,6 +67,9 @@ 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/quote.js"; export * from "./lenses/button.js"; export * from "./lenses/toggle.js"; export * from "./lenses/select.js"; @@ -80,6 +104,9 @@ 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 }, + Quote: { props: QuoteRuntimePropsSchema, description: QuoteDescription }, Button: { props: ButtonRuntimePropsSchema, description: ButtonDescription }, Toggle: { props: ToggleRuntimePropsSchema, description: ToggleDescription }, Select: { props: SelectRuntimePropsSchema, description: SelectDescription }, @@ -102,8 +129,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. @@ -210,5 +237,21 @@ 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; + } + 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/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/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/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/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/runtime/compatibility.ts b/packages/core/src/runtime/compatibility.ts index 14347f6..15d852a 100644 --- a/packages/core/src/runtime/compatibility.ts +++ b/packages/core/src/runtime/compatibility.ts @@ -20,19 +20,21 @@ export function validateNotebookCompatibility( const warnings: string[] = []; for (const cell of notebook.cells) { - if (cell.query?.source !== undefined) { - warnings.push( - `${cell.id}: query.source raw .gq is deprecated; prefer query.fixture structured DSL`, + if (cell.query?.ref !== undefined && !capabilities.namedQueries) { + errors.push( + `${cell.id}: selected source does not support named catalog queries (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)) { + if (cell.query?.rawGq !== undefined) { + if (!capabilities.rawGq) { + // Off by default in production/operator contexts — fatal unless the + // explicit dev/CLI escape hatch is enabled. errors.push( - `${cell.id}: selected source does not support ${kind} queries`, + `${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`, ); } } 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 76f05d9..aa4a026 100644 --- a/packages/core/src/spec/index.test.ts +++ b/packages/core/src/spec/index.test.ts @@ -2,25 +2,28 @@ 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 with a catalog query ref", () => { const yaml = ` version: 1 title: Demo -fixture: ./fixtures/x.json +server: http://127.0.0.1:8080 +graph: company 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.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"); + 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 @@ -28,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", () => { @@ -42,7 +45,7 @@ cells: version: 1 title: x cells: - - { id: t, lens: WhirlyGig, query: { source: "x" } } + - { id: t, lens: WhirlyGig, query: { ref: q } } `), ).toThrow(); }); @@ -55,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 @@ -68,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 @@ -88,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 @@ -164,12 +139,53 @@ cells: expect(nb.cells[1]?.on?.["press"]?.action).toBe("approve"); }); + it("rejects a removed overlay field (display, strict schema)", () => { + expect(() => + parseNotebook(` +version: 1 +title: x +cells: + - id: t + lens: Card + query: { ref: q } + display: drawer +`), + ).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(` version: 1 title: x -fixture: ./f.json cells: - { id: t, lens: Table, props: { columns: [{ key: x, label: X }] } } `), @@ -181,12 +197,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(); }); @@ -195,11 +210,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 0ee7b04..899567a 100644 --- a/packages/core/src/spec/index.ts +++ b/packages/core/src/spec/index.ts @@ -2,7 +2,15 @@ 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", + "Quote", +]); export type LensKind = z.infer; /** Interactive controls (cells without a query). */ @@ -15,6 +23,9 @@ export const ComponentKind = z.enum([ "Subgraph", "Path", "ActionList", + "Timeline", + "Card", + "Quote", "Button", "Toggle", "Select", @@ -22,16 +33,18 @@ 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 ────────────────────────────────────────────────────────── // 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,109 +72,36 @@ 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. - -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(), - // Fixture mode — used when notebook declares a top-level `fixture`. - 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", ); /** @@ -172,13 +112,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 @@ -210,7 +152,15 @@ export const CellSchema = z * Accepts a boolean, a state-condition object, or an array (AND). */ visible: z.unknown().optional(), + /** + * In-flow layout width (host-shell layout tier, web-first). The web host + * 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). + */ + width: z.enum(["full", "half", "third", "two-thirds"]).optional(), }) + .strict() .refine( (c) => { const isControl = @@ -224,21 +174,21 @@ export const CellSchema = z ); 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(), - /** - * 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/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/App.tsx b/packages/tui/src/App.tsx index 6682939..0ab23c7 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 `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/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/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/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/index.tsx b/packages/tui/src/index.tsx index cd2ca99..847717a 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,9 +24,8 @@ 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 { resolveConnection } from "@modernrelay/notebook-client/node"; import type { Source } from "@modernrelay/notebook-core"; import { App } from "./App.js"; @@ -36,6 +35,8 @@ interface ParsedArgs { token?: string; branch?: string; graph?: string; + profile?: string; + allowRawGq?: boolean; } function parseArgs(argv: readonly string[]): ParsedArgs { @@ -50,6 +51,10 @@ 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 === "--allow-raw-gq") { + out.allowRawGq = true; } else if (a === "-h" || a === "--help") { printUsage(); process.exit(0); @@ -66,16 +71,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] [--allow-raw-gq] - 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. + 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. `); } @@ -86,55 +88,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 (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 (!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 neither \`fixture:\` nor \`server:\`,\n` + - `and no --server flag was given. Set one of the three.\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 } : {}), + ...(args.allowRawGq ? { allowRawGq: true } : {}), + }); + const label = conn.label; + render( { const { statePath, value } = params as { statePath: string; value: unknown }; diff --git a/packages/web/package.json b/packages/web/package.json index 6a1a7a2..24b248a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,13 +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-fixture": "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.test.ts b/packages/web/src/App.test.ts index 103ae4f..7f4b423 100644 --- a/packages/web/src/App.test.ts +++ b/packages/web/src/App.test.ts @@ -6,60 +6,49 @@ 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); + // 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("loads notebook URL and resolves fixture relative to it", async () => { - stubWindow("http://127.0.0.1:5173/?notebook=/dash/notebook.yaml"); + 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) => { 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 - query: - fixture: { kind: nodes, where: { type: Decision } } + query: { ref: decisions_by_urgency } 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 +56,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..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, @@ -31,12 +49,25 @@ import { } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; 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 { widthToColSpan } from "./layout.js"; +import { + applyOverrides, + clearOverrides, + effectiveColSpan, + loadOverrides, + notebookKey, + saveOverrides, + spanToColSpan, + withOrder, + withSpan, + type LayoutOverrides, +} from "./layout-overrides.js"; type ConfigStatus = | { kind: "loading" } @@ -99,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); @@ -186,9 +244,8 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { handlers={handlers} > } header={ -

+

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

+ {editing && ( + + )} + - {config.mode} + server
@@ -231,13 +309,54 @@ function RuntimeApp({ config }: { config: AppConfig }): React.ReactElement { message={snapshot.error ?? "runtime failed"} /> )} - {snapshot.status === "ready" && ( -
- {snapshot.cells.map((cell) => ( - - ))} -
- )} + {snapshot.status === "ready" && + (() => { + // One canvas: every cell is a tile in the responsive 6-column grid. + // Dependent cells (queries reading `$state`) re-resolve in place when + // a selection changes — no overlay. Browser-local drag/resize + // overrides (order + width) layer over the declared layout. + const ordered = applyOverrides(snapshot.cells, overrides); + const orderedIds = ordered.map((c) => c.cell.id); + const handleDragEnd = (e: DragEndEvent): void => { + enableGridAnim(true); // dnd-kit handled the drag; animate the settle + const { active, over } = e; + if (!over || active.id === over.id) return; + const from = orderedIds.indexOf(String(active.id)); + const to = orderedIds.indexOf(String(over.id)); + if (from < 0 || to < 0) return; + updateOverrides(withOrder(overrides, arrayMove(orderedIds, from, to))); + }; + return ( + // In edit mode, drag a cell's handle to reorder and its right edge + // to resize. Collapses to one column below md. + enableGridAnim(false)} + onDragCancel={() => enableGridAnim(true)} + onDragEnd={handleDragEnd} + > + +
+ {ordered.map((cell) => ( + + updateOverrides(withSpan(overrides, cell.cell.id, span)) + } + /> + ))} +
+
+
+ ); + })()} {mutationError !== null && ( -
- {nav} -
- {header} -
{children}
-
+
+ {header} +
{children}
); } -function Sidebar({ - cells, -}: { - cells: Array<{ id: string }>; -}): React.ReactElement { - return ( - - ); -} - function humanizeCellId(id: string): string { // recent-decisions → Recent decisions. Notebook cell ids are slugs; // the dashboard reads them as human titles. Lower-cased second word @@ -328,77 +418,210 @@ function scrollToTop(): void { window.scrollTo({ top: 0, behavior: "smooth" }); } -function CellCard({ cell }: { cell: CellExecution }): React.ReactElement { +function CellCard({ + cell, + editing = false, + colSpanClass, + onResize, +}: { + cell: CellExecution; + editing?: boolean; + /** Effective inline col-span class (declared width or a resize override). */ + colSpanClass?: string; + /** Commit a resize (1–6 columns); absent in non-editable contexts. */ + onResize?: (span: number) => void; +}): React.ReactElement { const isControl = cell.cell.lens === "Button" || cell.cell.lens === "Toggle" || cell.cell.lens === "Select"; - // 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 filter controls stay crisp so the user can keep interacting. A - // failed re-read also keeps the stale spec (shown dimmed beneath the error). - const dimContent = (cell.pending || cell.error !== null) && cell.spec !== null; + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: cell.cell.id, disabled: !editing }); + // Live width preview while dragging the resize handle (committed on pointerup). + const [previewSpan, setPreviewSpan] = useState(null); + + const span = + previewSpan !== null + ? spanToColSpan(previewSpan) + : (colSpanClass ?? widthToColSpan(cell.cell.width)); return ( - - - - {humanizeCellId(cell.cell.id)} - - {cell.pending ? ( - - - - ) : ( - cell.error === null && - cell.result !== null && ( + + + + {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. 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 + * 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.controlSpecs.length > 0 && ( + {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/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/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/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/config.ts b/packages/web/src/config.ts index 2182521..be7c837 100644 --- a/packages/web/src/config.ts +++ b/packages/web/src/config.ts @@ -1,36 +1,31 @@ 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 { + // 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 { 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 +33,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) @@ -76,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) { @@ -90,12 +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}` : ""}`, - mode: "server", }; } +/** 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}`); 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 0000000..da51ac6 Binary files /dev/null and b/packages/web/src/layout-overrides.ts differ diff --git a/packages/web/src/layout.test.ts b/packages/web/src/layout.test.ts new file mode 100644 index 0000000..3c04afb --- /dev/null +++ b/packages/web/src/layout.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { widthToColSpan } from "./layout.js"; + +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 new file mode 100644 index 0000000..262454d --- /dev/null +++ b/packages/web/src/layout.ts @@ -0,0 +1,28 @@ +import type { Cell } from "@modernrelay/notebook-core"; + +/** + * 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 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. + */ +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"; + } +} diff --git a/packages/web/src/registry.ts b/packages/web/src/registry.ts index 8a74925..d60cf68 100644 --- a/packages/web/src/registry.ts +++ b/packages/web/src/registry.ts @@ -6,6 +6,9 @@ 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 { Quote } from "./components/Quote.js"; import { Button } from "./components/Button.js"; import { Toggle } from "./components/Toggle.js"; import { Select } from "./components/Select.js"; @@ -18,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, Button, Toggle, Select }, + components: { Table, Path, Subgraph, ActionList, Timeline, Card, Quote, Button, Toggle, Select }, actions: { setState: async (params, setState) => { const { statePath, value } = params as { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 702e725..668fc40 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 @@ -69,6 +66,9 @@ importers: '@modernrelay/omnigraph': specifier: ^0.7.0 version: 0.7.0 + yaml: + specifier: ^2.8.4 + version: 2.8.4 devDependencies: vitest: specifier: ^4.1.5 @@ -90,19 +90,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 +104,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) @@ -145,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) @@ -163,9 +159,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 @@ -244,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==} @@ -430,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==} @@ -1585,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 @@ -1700,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 @@ -2487,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: diff --git a/refs/r1.jpg b/refs/r1.jpg new file mode 100644 index 0000000..cdc9180 Binary files /dev/null and b/refs/r1.jpg differ diff --git a/refs/r2.jpg b/refs/r2.jpg new file mode 100644 index 0000000..88fe6d7 Binary files /dev/null and b/refs/r2.jpg differ 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" <