From b5bfa40fbbe5adbd3a64d25bc6d7134e493b60c9 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 16 Jun 2026 13:52:42 +0200 Subject: [PATCH 1/3] feat!: migrate SDK + MCP to omnigraph-server v0.7.0 (cluster-only) Sync the SDK to omnigraph-server v0.7.0. The dominant upstream change is cluster-only serving (RFC-011 #250): every operational endpoint moves from `/x` to `/graphs/{graph_id}/x`; only `GET /healthz` and `GET /graphs` stay flat. Also: canonical `POST /load` (deprecating `/ingest`, #222), optional `expect_mutation` on stored-query invoke (#247), and a 409 on schema apply for cluster-managed graphs (#258). Breaking (hard cutover, per repo major.minor=server policy): - `graphId` is now required for graph-scoped operations. The transport throws the new typed `ConfigurationError` (status 0) before any network call when a graph-scoped method runs without one. `health()` and `graphs.list()` remain graph-independent. SDK 0.7.x targets a 0.7.x server only. SDK: - add `og.load()` (canonical); mark `og.ingest()` deprecated (server keeps it as a shim with Deprecation/Link headers) - `expectMutation` passthrough on `queries.invoke()` - regen types from v0.7.0 spec: `IngestOutput.base_branch` now nullable/optional; `InvokeRequest.expect_mutation` added - export `ConfigurationError` MCP: - add `load` tool (canonical); `ingest` becomes the deprecated alias - require `OMNIGRAPH_GRAPH_ID` (cluster-only) Tooling/CI: - `check-coverage` is graphId-prefix-aware (mirrors the transport rewrite) - e2e rewritten to boot a local-filesystem cluster (cluster import/apply/load, `--cluster` boot) with Cedar policy bundles granting `graph_list` + per-graph data actions; collapsed the two jobs into one (alpha + beta) - bump all three versions to 0.7.0; README/MCP docs updated for the cluster model - sync-cookbook: skills moved to ModernRelay/omnigraph (skills/omnigraph/references) Verified end-to-end against the real v0.7.0 server binary: 90 unit + 21 live e2e tests pass; full gate (versions/drift/coverage/build/typecheck/test) green. --- .github/workflows/e2e.yml | 220 +++-------- README.md | 7 +- package.json | 2 +- packages/mcp/README.md | 11 +- packages/mcp/cookbook-descriptions.json | 7 +- packages/mcp/package.json | 2 +- packages/mcp/scripts/sync-cookbook.ts | 11 +- packages/mcp/src/bin.ts | 13 +- packages/mcp/src/server.ts | 50 ++- packages/mcp/src/version.gen.ts | 2 +- packages/mcp/test/server.test.ts | 38 +- packages/sdk/README.md | 41 +- packages/sdk/package.json | 2 +- packages/sdk/src/client.ts | 34 +- packages/sdk/src/errors.ts | 11 + packages/sdk/src/generated/index.ts | 173 ++++----- packages/sdk/src/generated/types.gen.ts | 476 ++++++++++++++++-------- packages/sdk/src/index.ts | 1 + packages/sdk/src/resources/graphs.ts | 11 +- packages/sdk/src/resources/queries.ts | 4 + packages/sdk/src/transport.ts | 22 +- packages/sdk/src/version.gen.ts | 2 +- packages/sdk/test/branches.test.ts | 18 +- packages/sdk/test/client.test.ts | 61 ++- packages/sdk/test/commits.test.ts | 14 +- packages/sdk/test/e2e.test.ts | 140 +++---- packages/sdk/test/enums.test.ts | 2 +- packages/sdk/test/errors.test.ts | 4 +- packages/sdk/test/graphs.test.ts | 10 +- packages/sdk/test/opaque.test.ts | 6 +- packages/sdk/test/queries.test.ts | 32 +- packages/sdk/test/schema.test.ts | 38 +- packages/sdk/test/stream.test.ts | 6 +- packages/sdk/test/transport.test.ts | 74 ++-- scripts/check-coverage.ts | 16 +- spec/openapi.json | 440 ++++++++++++++++++---- 36 files changed, 1289 insertions(+), 712 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index dfe2c9a..4ae5bf7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -51,196 +51,98 @@ jobs: echo "version mismatch: pinned $v, got $got"; exit 1 fi - - name: Init repo and load fixture + # 0.7.0 is cluster-only: the server boots solely from `--cluster ` and + # serves every graph under /graphs/{id}/…. Build a local-filesystem cluster + # (no object storage) declaring two graphs, alpha + beta, with Cedar policy + # bundles: a `cluster`-scoped bundle granting `graph_list` (so GET /graphs + # works) and a per-graph bundle granting the data-plane actions the SDK + # exercises. Auth is on (token → actor `default`) so the UnauthorizedError + # path is covered too. + - name: Build local cluster (alpha + beta) with policy run: | set -euo pipefail - mkdir -p /tmp/og - omnigraph init --schema packages/sdk/test/fixtures/schema.pg /tmp/og/repo.omni - omnigraph load --data packages/sdk/test/fixtures/data.jsonl --mode overwrite /tmp/og/repo.omni + dir=/tmp/cluster + mkdir -p "$dir" + cp packages/sdk/test/fixtures/schema.pg "$dir/graph.pg" - - name: Write Cedar policy + omnigraph.yaml (single-graph) - run: | - # v0.6 default-denies non-read actions when a token is configured but - # no policy is set. Authorize the implicit `default` actor (used by - # OMNIGRAPH_SERVER_BEARER_TOKEN) for every action this e2e exercises. - cat > /tmp/og/policy.yaml <<'YAML' + cat > "$dir/server.policy.yaml" <<'YAML' version: 1 groups: ci: [default] rules: - - id: ci-all-actions + - id: ci-graph-list allow: actors: { group: ci } - actions: - - read - - export - - change - - schema_apply - - branch_create - - branch_delete - - branch_merge - YAML - cat > /tmp/og/omnigraph.yaml <<'YAML' - graphs: - e2e: - uri: /tmp/og/repo.omni - # Per-graph policy: server 0.6.1+ rejects a top-level `policy.file` - # alongside a named graph (it would be silently ignored), so the - # policy must live under the graph it applies to. - policy: - file: /tmp/og/policy.yaml - cli: - graph: e2e - branch: main + actions: [graph_list] YAML - - name: Start omnigraph-server in background (single-graph) - run: | - set -euo pipefail - OMNIGRAPH_SERVER_BEARER_TOKEN=ci-token \ - nohup omnigraph-server --target e2e --config /tmp/og/omnigraph.yaml --bind 127.0.0.1:18080 \ - > /tmp/og/server.log 2>&1 & - echo $! > /tmp/og/server.pid - for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:18080/healthz > /dev/null; then - echo "server up after ${i}s"; exit 0 - fi - sleep 1 - done - echo "server failed to start within 30s"; cat /tmp/og/server.log; exit 1 - - - name: Run single-graph e2e tests - env: - OMNIGRAPH_E2E: '1' - OMNIGRAPH_BASE_URL: http://127.0.0.1:18080 - OMNIGRAPH_TOKEN: ci-token - run: pnpm --filter @modernrelay/omnigraph run test - - - name: Stop server - if: always() - run: | - if [ -f /tmp/og/server.pid ]; then - kill "$(cat /tmp/og/server.pid)" || true - fi - - - name: Upload server log on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: omnigraph-server-log - path: /tmp/og/server.log - - e2e-multigraph: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - - run: pnpm install --frozen-lockfile - - - name: Read pinned server version - id: ver - run: | - v=$(node -p "require('./package.json').omnigraph.serverVersion") - echo "version=$v" >> "$GITHUB_OUTPUT" - - - name: Download omnigraph-server binary - run: | - set -euo pipefail - v="${{ steps.ver.outputs.version }}" - asset="omnigraph-linux-x86_64.tar.gz" - checksum="omnigraph-linux-x86_64.sha256" - mkdir -p "$HOME/.local/bin" - curl -fsSL -o "/tmp/${asset}" \ - "https://github.com/ModernRelay/omnigraph/releases/download/v${v}/${asset}" - curl -fsSL -o "/tmp/${checksum}" \ - "https://github.com/ModernRelay/omnigraph/releases/download/v${v}/${checksum}" - (cd /tmp && sha256sum -c "${checksum}") - tar -C "$HOME/.local/bin" -xzf "/tmp/${asset}" - chmod +x "$HOME/.local/bin/omnigraph" "$HOME/.local/bin/omnigraph-server" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - - name: Init two graphs (alpha, beta) from the same fixture - run: | - set -euo pipefail - mkdir -p /tmp/ogm - for g in alpha beta; do - omnigraph init --schema packages/sdk/test/fixtures/schema.pg "/tmp/ogm/${g}.omni" - omnigraph load --data packages/sdk/test/fixtures/data.jsonl --mode overwrite "/tmp/ogm/${g}.omni" - done - - - name: Write server + per-graph policy files - run: | - # Server-scoped policy: authorize `graph_list` for the default actor. - # /graphs is closed by default in every state — even unauthenticated. - cat > /tmp/ogm/server-policy.yaml <<'YAML' + cat > "$dir/graph.policy.yaml" <<'YAML' version: 1 groups: ci: [default] rules: - - id: ci-can-list-graphs + - id: ci-data-plane allow: actors: { group: ci } - actions: [graph_list] + actions: [read, export, change, schema_apply, branch_create, branch_delete, branch_merge, invoke_query] YAML - # Per-graph policy for alpha: authorize every per-graph action the - # SDK e2e exercises. beta gets the same so `og.graph("beta")` works. - cat > /tmp/ogm/per-graph-policy.yaml <<'YAML' + + cat > "$dir/cluster.yaml" <<'YAML' version: 1 - groups: - ci: [default] - rules: - - id: ci-all-actions - allow: - actors: { group: ci } - actions: - - read - - export - - change - - schema_apply - - branch_create - - branch_delete - - branch_merge - YAML - cat > /tmp/ogm/omnigraph.yaml <<'YAML' - server: - policy: - file: /tmp/ogm/server-policy.yaml + metadata: + name: e2e + state: + backend: cluster + lock: true graphs: alpha: - uri: /tmp/ogm/alpha.omni - policy: - file: /tmp/ogm/per-graph-policy.yaml + schema: ./graph.pg beta: - uri: /tmp/ogm/beta.omni - policy: - file: /tmp/ogm/per-graph-policy.yaml + schema: ./graph.pg + policies: + server: + file: ./server.policy.yaml + applies_to: [cluster] + data: + file: ./graph.policy.yaml + applies_to: [alpha, beta] YAML - - name: Start omnigraph-server in multi-graph mode + omnigraph cluster import --config "$dir" + omnigraph cluster apply --config "$dir" + + - name: Seed fixture data into each graph + run: | + set -euo pipefail + dir=/tmp/cluster + # `--mode` is required on load; local file:// writes need no `--yes`. + for g in alpha beta; do + omnigraph load \ + --data packages/sdk/test/fixtures/data.jsonl \ + --mode overwrite \ + "$dir/graphs/${g}.omni" + done + + - name: Start omnigraph-server (cluster) in background run: | set -euo pipefail + dir=/tmp/cluster OMNIGRAPH_SERVER_BEARER_TOKEN=ci-token \ - nohup omnigraph-server --config /tmp/ogm/omnigraph.yaml --bind 127.0.0.1:18081 \ - > /tmp/ogm/server.log 2>&1 & - echo $! > /tmp/ogm/server.pid + nohup omnigraph-server --cluster "$dir" --bind 127.0.0.1:18080 \ + > "$dir/server.log" 2>&1 & + echo $! > "$dir/server.pid" for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:18081/healthz > /dev/null; then + if curl -sf http://127.0.0.1:18080/healthz > /dev/null; then echo "server up after ${i}s"; exit 0 fi sleep 1 done - echo "server failed to start within 30s"; cat /tmp/ogm/server.log; exit 1 + echo "server failed to start within 30s"; cat "$dir/server.log"; exit 1 - - name: Run multi-graph e2e tests + - name: Run e2e tests (cluster, graphId=alpha) env: OMNIGRAPH_E2E: '1' - OMNIGRAPH_E2E_MULTIGRAPH: '1' - OMNIGRAPH_BASE_URL: http://127.0.0.1:18081 + OMNIGRAPH_BASE_URL: http://127.0.0.1:18080 OMNIGRAPH_TOKEN: ci-token OMNIGRAPH_GRAPH_ID: alpha run: pnpm --filter @modernrelay/omnigraph run test @@ -248,13 +150,13 @@ jobs: - name: Stop server if: always() run: | - if [ -f /tmp/ogm/server.pid ]; then - kill "$(cat /tmp/ogm/server.pid)" || true + if [ -f /tmp/cluster/server.pid ]; then + kill "$(cat /tmp/cluster/server.pid)" || true fi - name: Upload server log on failure if: failure() uses: actions/upload-artifact@v4 with: - name: omnigraph-server-multigraph-log - path: /tmp/ogm/server.log + name: omnigraph-server-log + path: /tmp/cluster/server.log diff --git a/README.md b/README.md index 224fdbd..12d60c0 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,11 @@ pnpm run build # builds all workspace packages (SDK first, then MCP) pnpm run typecheck # runs after build so workspace types resolve pnpm run test # mocked unit tests across all packages -# Live e2e against a real server (requires omnigraph-server v$(jq -r .omnigraph.serverVersion package.json) running): -OMNIGRAPH_E2E=1 OMNIGRAPH_BASE_URL=http://127.0.0.1:8080 OMNIGRAPH_TOKEN=$TOKEN pnpm --filter @modernrelay/omnigraph run test +# Live e2e against a real cluster server (0.7.0 is cluster-only — boot it with +# `omnigraph-server --cluster `; see packages/sdk/test/e2e.test.ts header +# and .github/workflows/e2e.yml for the cluster.yaml + import/apply/load setup): +OMNIGRAPH_E2E=1 OMNIGRAPH_BASE_URL=http://127.0.0.1:8080 OMNIGRAPH_TOKEN=$TOKEN \ + OMNIGRAPH_GRAPH_ID=alpha pnpm --filter @modernrelay/omnigraph run test ``` CI runs the same sequence (see `.github/workflows/ci.yml` and `e2e.yml`) and downloads the pinned `omnigraph-server` release binary for the e2e job. diff --git a/package.json b/package.json index 44d2dc2..1a9dabd 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "packageManager": "pnpm@9.15.0", "omnigraph": { - "serverVersion": "0.6.1" + "serverVersion": "0.7.0" }, "scripts": { "sync-spec": "tsx scripts/sync-spec.ts", diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 89d7fdb..d24c629 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -32,7 +32,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' const server = createOmnigraphMcpServer({ baseUrl: 'http://127.0.0.1:8080', token: process.env.OMNIGRAPH_TOKEN, - graphId: 'alpha', // optional — set when talking to a multi-graph cluster + graphId: 'alpha', // required — omnigraph-server 0.7.0 is cluster-only }); await server.connect(new StdioServerTransport()); ``` @@ -42,9 +42,9 @@ await server.connect(new StdioServerTransport()); | Variable | Purpose | |---|---| | `OMNIGRAPH_BASE_URL` | Required. `omnigraph-server` URL. | +| `OMNIGRAPH_GRAPH_ID` | Required (server 0.7.0+ is cluster-only). Graph this server operates on — routes every graph-scoped call under `/graphs/${id}/...`. The `bin` entrypoint refuses to start without it. | | `OMNIGRAPH_TOKEN` | Optional bearer token. Required against a server with auth enabled. | | `OMNIGRAPH_DEFAULT_BRANCH` | Branch used when a tool input omits one. Defaults to `main`. | -| `OMNIGRAPH_GRAPH_ID` | Optional. Target graph id in a multi-graph cluster — routes every graph-scoped call under `/graphs/${id}/...`. Leave unset for single-graph servers. | ## Surface @@ -62,7 +62,7 @@ Read-only (`readOnlyHint: true`): | `branches_list` | List user-visible branches | | `commits_list` | List commits on a branch | | `commits_get` | Retrieve a single commit | -| `graphs_list` | List registered graphs (multi-graph servers; requires `graph_list` policy) | +| `graphs_list` | List registered graphs in the cluster (requires a `graph_list` policy grant) | Mutating (`destructiveHint: true` where appropriate — hosts should surface confirmation): @@ -70,7 +70,8 @@ Mutating (`destructiveHint: true` where appropriate — hosts should surface con |---|---| | `mutate` | Run a `.gq` mutation (canonical; successor to `change`) | | `change` | Legacy alias for `mutate`. Accepts either legacy `querySource` / `queryName` or canonical `query` / `name`; mixed field families are rejected. Prefer `mutate`. | -| `ingest` | Bulk-ingest NDJSON (`mode: 'merge'` for idempotency) | +| `load` | Bulk-load NDJSON (canonical; `mode: 'merge'` for idempotency). Without `from`, a missing branch is a 404. | +| `ingest` | Deprecated alias of `load` (kept as a shim). Identical behavior; prefer `load`. | | `branches_create` | Create a new branch | | `branches_delete` | Delete a branch | | `branches_merge` | Merge `source` into `target` | @@ -80,7 +81,7 @@ Mutating (`destructiveHint: true` where appropriate — hosts should surface con - `omnigraph://schema` — text/plain `.pg` source - `omnigraph://branches` — application/json branch name list -- `omnigraph://graphs` — application/json `[{ graphId, uri }]` (multi-graph servers only; single-graph servers return 405) +- `omnigraph://graphs` — application/json `[{ graphId, uri }]` (requires a `graph_list` policy grant; the management surface is closed by default) ## License diff --git a/packages/mcp/cookbook-descriptions.json b/packages/mcp/cookbook-descriptions.json index 1bcd9d5..e47d64b 100644 --- a/packages/mcp/cookbook-descriptions.json +++ b/packages/mcp/cookbook-descriptions.json @@ -22,8 +22,11 @@ } }, "skipped": { - "aliases": "CLI-only — omnigraph.yaml + --alias mechanics, not actionable through the HTTP API the MCP wraps.", + "aliases": "CLI-only — operator aliases + --alias mechanics, not actionable through the HTTP API the MCP wraps.", "commands": "CLI-only — the omnigraph binary's command surface, not the HTTP API.", - "server-policy": "Server-deployment concerns (Cedar policy, auth tokens), out of scope for an MCP client." + "server-policy": "Server-deployment concerns (Cedar policy, auth tokens), out of scope for an MCP client.", + "cluster": "Operator/CLI concern — declaring and converging a deployment via cluster.yaml + `omnigraph cluster apply`. The MCP wraps the data-plane HTTP API, which cannot apply cluster config.", + "migrations": "Pre-0.7.0 → 0.7.0 migration reference for config files, CLI flags, and command spellings — a CLI/operator guide, not actionable through the HTTP API the MCP wraps.", + "stored-queries": "Stored-query declaration is a cluster/operator concern (cluster.yaml); the MCP wraps the data-plane HTTP API and does not currently expose a stored-query invoke tool." } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 4e0f743..947c114 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@modernrelay/omnigraph-mcp", - "version": "0.6.1", + "version": "0.7.0", "description": "MCP server exposing an Omnigraph database to LLM clients (Tools + Resources, stdio transport).", "license": "MIT", "repository": { diff --git a/packages/mcp/scripts/sync-cookbook.ts b/packages/mcp/scripts/sync-cookbook.ts index 1e9826a..7432b50 100644 --- a/packages/mcp/scripts/sync-cookbook.ts +++ b/packages/mcp/scripts/sync-cookbook.ts @@ -16,9 +16,10 @@ // Hand-written guidance ("Read before authoring queries…") gives the LLM // the cue it needs. // -// Source of truth: ModernRelay/omnigraph-cookbooks @ main. The generated -// TS module is gitignored; every build/typecheck regenerates it. CI builds -// always fetch fresh; the published npm tarball ships the bundled JS with +// Source of truth: the `omnigraph` skill references in ModernRelay/omnigraph @ +// main (moved there from the retired ModernRelay/omnigraph-cookbooks repo). The +// generated TS module is gitignored; every build/typecheck regenerates it. CI +// builds always fetch fresh; the published npm tarball ships the bundled JS with // the markdown inlined as string constants. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; @@ -30,9 +31,9 @@ const PKG_ROOT = dirname(HERE); const OUT = join(PKG_ROOT, 'src/best-practices.gen.ts'); const DESCRIPTIONS_PATH = join(PKG_ROOT, 'cookbook-descriptions.json'); -const REPO = 'ModernRelay/omnigraph-cookbooks'; +const REPO = 'ModernRelay/omnigraph'; const REF = 'main'; -const REF_DIR = 'skills/omnigraph-best-practices/references'; +const REF_DIR = 'skills/omnigraph/references'; const LIST_URL = `https://api.github.com/repos/${REPO}/contents/${REF_DIR}?ref=${REF}`; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${REF}/${REF_DIR}`; diff --git a/packages/mcp/src/bin.ts b/packages/mcp/src/bin.ts index 2ac001b..13a0d66 100644 --- a/packages/mcp/src/bin.ts +++ b/packages/mcp/src/bin.ts @@ -13,11 +13,22 @@ if (!baseUrl) { process.exit(1); } +// omnigraph-server 0.7.0+ is cluster-only: every graph-scoped tool is served +// under /graphs/{graphId}/…, so the MCP server must be pinned to one graph. +const graphId = process.env.OMNIGRAPH_GRAPH_ID; +if (!graphId) { + console.error( + 'OMNIGRAPH_GRAPH_ID is required (omnigraph-server 0.7.0+ is cluster-only). ' + + 'Set it to the graph this server should operate on.', + ); + process.exit(1); +} + const server = createOmnigraphMcpServer({ baseUrl, token: process.env.OMNIGRAPH_TOKEN, defaultBranch: process.env.OMNIGRAPH_DEFAULT_BRANCH, - graphId: process.env.OMNIGRAPH_GRAPH_ID, + graphId, }); await server.connect(new StdioServerTransport()); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b76f4d0..4d91bdd 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -47,7 +47,7 @@ Date format: ISO strings on \`change\` params; integer days-since-epoch in inges If you see \`sync_branch()\` in an error message, it is server-internal text, NOT a tool. Retry once; on persistent failure, fall back to \`ingest\` on a branch. -Depth: https://github.com/ModernRelay/omnigraph-cookbooks/tree/main/skills/omnigraph-best-practices`; +Depth: https://github.com/ModernRelay/omnigraph/tree/main/skills/omnigraph`; export interface CreateServerOptions { baseUrl: string; @@ -55,9 +55,10 @@ export interface CreateServerOptions { /** Default branch when a tool input omits one. */ defaultBranch?: string; /** - * Multi-graph cluster: target graph id. Threaded into the underlying - * `Omnigraph` client so every tool call routes under `/graphs/${graphId}/...`. - * Leave undefined for single-graph servers. + * Target graph id. Threaded into the underlying `Omnigraph` client so every + * graph-scoped tool call routes under `/graphs/${graphId}/...`. Required + * against omnigraph-server 0.7.0+ (cluster-only); the `bin` entrypoint + * refuses to start without `OMNIGRAPH_GRAPH_ID`. */ graphId?: string; /** Custom fetch (for testing). */ @@ -262,10 +263,10 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { { title: 'List registered graphs', description: - 'Return every graph the server exposes, alphabetically by graphId. ' + - 'Multi-graph servers only — a single-graph server returns 405 ' + - '(MethodNotAllowedError). When a token is configured the server-level ' + - 'Cedar policy must authorize the `graph_list` action.', + 'Return every graph the cluster exposes, alphabetically by graphId. ' + + 'The `/graphs` management surface is closed by default — the cluster ' + + 'must grant the `graph_list` action (a `cluster`-scoped policy bundle) ' + + 'or this returns 403 (ForbiddenError).', inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false }, }, @@ -352,12 +353,35 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { }, ); + server.registerTool( + 'load', + { + title: 'Bulk-load NDJSON', + description: + 'Bulk-load NDJSON data into a branch (canonical, server 0.7.0+). `mode: "merge"` upserts by @key (idempotent). ' + + '`mode: "append"` is strict insert (errors on duplicate). `mode: "overwrite"` replaces all data. ' + + 'Without `from`, the target branch must already exist (a missing branch is a 404); pass `from` to fork-if-missing.', + inputSchema: { + branch: z.string().min(1), + from: z.string().optional(), + mode: LoadModeEnum, + data: z.string().min(1), + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + }, + async ({ branch, from, mode, data }) => { + const r = await og.load({ branch, from, mode, data }); + return { content: jsonText(r) }; + }, + ); + server.registerTool( 'ingest', { - title: 'Bulk-ingest NDJSON', + title: 'Bulk-ingest NDJSON (deprecated alias of load)', description: - 'Bulk-load NDJSON data into a branch. `mode: "merge"` upserts by @key (idempotent). ' + + 'Deprecated alias of `load` (kept indefinitely as a shim). Identical behavior; prefer `load`. ' + + '`mode: "merge"` upserts by @key (idempotent). ' + '`mode: "append"` is strict insert (errors on duplicate). `mode: "overwrite"` replaces all data.', inputSchema: { branch: z.string().min(1), @@ -474,8 +498,8 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { { title: 'Graphs', description: - 'JSON array of registered graphs, each `{ graphId, uri }`. Multi-graph ' + - 'servers only — single-graph servers return 405 on `GET /graphs`.', + 'JSON array of registered graphs, each `{ graphId, uri }`. The `/graphs` ' + + 'management surface is closed by default — requires a `graph_list` policy grant.', mimeType: 'application/json', }, async (uri) => { @@ -528,7 +552,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { const lines = [ '# Omnigraph best-practices index', '', - 'Vendored from https://github.com/ModernRelay/omnigraph-cookbooks/tree/main/skills/omnigraph-best-practices.', + 'Vendored from https://github.com/ModernRelay/omnigraph/tree/main/skills/omnigraph.', '', '| Resource | Read before |', '|---|---|', diff --git a/packages/mcp/src/version.gen.ts b/packages/mcp/src/version.gen.ts index 2b0f70d..d4ea02e 100644 --- a/packages/mcp/src/version.gen.ts +++ b/packages/mcp/src/version.gen.ts @@ -4,4 +4,4 @@ /** * The MCP server package version reported in the MCP initialize response. */ -export const MCP_PACKAGE_VERSION = "0.6.1"; +export const MCP_PACKAGE_VERSION = "0.7.0"; diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 7e08533..e3fae0e 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -3,13 +3,21 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { describe, expect, it } from 'vitest'; import { createOmnigraphMcpServer } from '../src/server'; +// Recover the flat operation path from a (possibly graph-scoped) URL. The +// server is configured with a graphId, so the transport sends graph-scoped +// ops under /graphs/{id}/…; strip that prefix so the fakes below can match on +// `/read`, `/branches`, … unchanged. Flat paths (/healthz, /graphs) pass through. +function flatPath(url: string): string { + return new URL(url).pathname.replace(/^\/graphs\/[^/]+/, ''); +} + // A stub fetch that emulates a small slice of omnigraph-server. We don't // want a real server in the unit tests; we just want to verify the MCP // wiring (tool registration, schema, dispatch, response shape) works. function fakeFetch(): typeof globalThis.fetch { return (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = new URL(url).pathname; + const path = flatPath(url); const method = init?.method ?? 'GET'; const respond = (status: number, body: unknown, headers: Record = {}) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { @@ -71,7 +79,7 @@ function fakeFetch(): typeof globalThis.fetch { } async function setup() { - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', fetch: fakeFetch() }); + const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: fakeFetch() }); const client = new Client({ name: 'test-client', version: '0.0.0' }); const [clientT, serverT] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverT), client.connect(clientT)]); @@ -83,7 +91,7 @@ describe('omnigraph-mcp server', () => { const { client } = await setup(); const info = client.getServerVersion(); expect(info?.name).toBe('omnigraph-mcp'); - expect(info?.version).toBe('0.6.1'); + expect(info?.version).toBe('0.7.0'); }); it('lists every expected tool', async () => { @@ -102,6 +110,7 @@ describe('omnigraph-mcp server', () => { 'graphs_list', 'health', 'ingest', + 'load', 'mutate', 'query', 'read', @@ -117,6 +126,7 @@ describe('omnigraph-mcp server', () => { const { tools } = await client.listTools(); const byName = new Map(tools.map((t) => [t.name, t])); expect(byName.get('change')?.annotations?.destructiveHint).toBe(true); + expect(byName.get('load')?.annotations?.destructiveHint).toBe(true); expect(byName.get('ingest')?.annotations?.destructiveHint).toBe(true); expect(byName.get('branches_delete')?.annotations?.destructiveHint).toBe(true); expect(byName.get('branches_merge')?.annotations?.destructiveHint).toBe(true); @@ -133,7 +143,7 @@ describe('omnigraph-mcp server', () => { const parsed = JSON.parse(block.text); expect(parsed.status).toBe('ok'); expect(parsed.version).toBe('0.3.0'); - expect(parsed.sdkServerVersion).toBe('0.6.1'); + expect(parsed.sdkServerVersion).toBe('0.7.0'); }); it('calls the read tool and preserves opaque param keys', async () => { @@ -158,7 +168,7 @@ describe('omnigraph-mcp server', () => { let observedBody: Record | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = new URL(url).pathname; + const path = flatPath(url); const method = init?.method ?? 'GET'; if (method === 'POST' && path === '/change') { observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); @@ -170,7 +180,7 @@ describe('omnigraph-mcp server', () => { return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); }) as unknown as typeof globalThis.fetch; - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', fetch: recordingFetch }); + const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: recordingFetch }); const client = new Client({ name: 'test', version: '0.0.0' }); const [clientT, serverT] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverT), client.connect(clientT)]); @@ -194,7 +204,7 @@ describe('omnigraph-mcp server', () => { let observedBody: Record | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = new URL(url).pathname; + const path = flatPath(url); const method = init?.method ?? 'GET'; if (method === 'POST' && path === '/change') { observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); @@ -206,7 +216,7 @@ describe('omnigraph-mcp server', () => { return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); }) as unknown as typeof globalThis.fetch; - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', fetch: recordingFetch }); + const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: recordingFetch }); const client = new Client({ name: 'test', version: '0.0.0' }); const [clientT, serverT] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverT), client.connect(clientT)]); @@ -300,7 +310,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as let observedFrom: string | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = new URL(url).pathname; + const path = flatPath(url); const method = init?.method ?? 'GET'; if (method === 'POST' && path === '/branches') { const body = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); @@ -314,7 +324,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as }) as unknown as typeof globalThis.fetch; const server = createOmnigraphMcpServer({ - baseUrl: 'http://x', + baseUrl: 'http://x', graphId: 'g', defaultBranch: 'review-2026', fetch: recordingFetch, }); @@ -330,7 +340,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as let observedBody: Record | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = new URL(url).pathname; + const path = flatPath(url); const method = init?.method ?? 'GET'; if (method === 'POST' && path === '/read') { observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); @@ -349,7 +359,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as }) as unknown as typeof globalThis.fetch; const server = createOmnigraphMcpServer({ - baseUrl: 'http://x', + baseUrl: 'http://x', graphId: 'g', defaultBranch: 'main', fetch: recordingFetch, }); @@ -369,7 +379,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as let observedBody: Record | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = new URL(url).pathname; + const path = flatPath(url); const method = init?.method ?? 'GET'; if (method === 'POST' && path === '/schema/apply') { observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); @@ -381,7 +391,7 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); }) as unknown as typeof globalThis.fetch; - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', fetch: recordingFetch }); + const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: recordingFetch }); const client = new Client({ name: 'test', version: '0.0.0' }); const [clientT, serverT] = InMemoryTransport.createLinkedPair(); await Promise.all([server.connect(serverT), client.connect(clientT)]); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 1203902..53b8be1 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -20,6 +20,7 @@ import Omnigraph from '@modernrelay/omnigraph'; const og = new Omnigraph({ baseUrl: 'http://127.0.0.1:8080', + graphId: 'alpha', // required — every graph-scoped call routes under /graphs/alpha/… token: process.env.OMNIGRAPH_TOKEN, // optional; omit for unauthenticated dev }); @@ -33,7 +34,9 @@ const { rows } = await og.query({ console.log(rows); // → [{ '$p.name': 'Alice', '$p.age': 30 }] ``` -That's the whole pattern: instantiate once, call methods, get typed responses. +That's the whole pattern: instantiate once (with a `graphId`), call methods, get typed responses. + +> **`graphId` is required (server 0.7.0).** `omnigraph-server` is cluster-only: every graph-scoped operation is served under `/graphs/{graphId}/…`. A graph-scoped call without a `graphId` throws `ConfigurationError` before hitting the network. Only `og.health()` and `og.graphs.list()` work without one — use the latter to discover ids, then [`og.graph(id)`](#multi-graph-clusters). This SDK major.minor targets a 0.7.x server; for a 0.6.x (flat-route) server, stay on `@modernrelay/omnigraph@0.6.x`. > **Migrating from `og.read` / `og.change` (server 0.6.0)** — `POST /query` and `POST /mutate` are the canonical successors. New mutation calls should use `og.mutate({ query, name })`. Deprecated `og.change()` remains source-compatible with both old `{ querySource, queryName }` callers and canonical `{ query, name }` callers; the SDK normalizes either shape to the server 0.6 wire body. `og.read()` keeps the legacy `{ querySource, queryName }` shape. @@ -75,19 +78,21 @@ const { outcome } = await og.branches.merge({ source: 'feature', target: 'main' await og.branches.delete('feature'); ``` -### Bulk ingest +### Bulk load ```ts import { LoadMode } from '@modernrelay/omnigraph'; -await og.ingest({ +await og.load({ branch: 'import-2026-04-30', - from: 'main', - mode: LoadMode.MERGE, // upsert by @key — safe to retry + from: 'main', // required to fork a missing branch — without it a missing branch is a 404 + mode: LoadMode.MERGE, // upsert by @key — safe to retry data: ndjsonString, }); ``` +`og.load()` is the canonical bulk-load endpoint (server 0.7.0). `og.ingest()` is a deprecated alias kept for back-compat — identical request/response shape; the server emits `Deprecation`/`Link` headers. **Loading into a branch that doesn't exist requires `from`** (the base to fork from); without it the server returns `NotFoundError` (404) rather than implicitly forking from `main`. + ### Stream a branch as NDJSON ```ts @@ -142,13 +147,15 @@ try { } else if (e instanceof NotFoundError) { // 404 } else if (e instanceof MethodNotAllowedError) { - // 405 — typically from `og.graphs.list()` on a single-graph server + // 405 } else throw e; } ``` Every error carries `status`, `code`, `requestId` (from the `X-Request-Id` response header), and the parsed response body for diagnostics. +`ConfigurationError` is the one error thrown **client-side, before any request** — it means a graph-scoped method was called without a `graphId` configured (see [the required-`graphId` note](#first-call)). Its `status` is `0`, like `NetworkError`. + ## Cancellation Every method accepts an `AbortSignal`: @@ -190,18 +197,18 @@ Omnigraph is a database; idempotency belongs in the schema (`@key`, `@unique`), | `og.branches.merge({ source, target })` | Idempotent — re-merge yields `outcome: 'already_up_to_date'`. | | `og.branches.delete(name)` | Idempotent — delete-of-deleted is a no-op. | | `og.schema.apply({ schemaSource })` | Idempotent — unchanged schema returns `applied: false`. | -| `og.ingest({ data, mode: 'merge' })` | **Idempotent** — use this mode for at-least-once pipelines. Requires `@key` constraints. | -| `og.ingest({ data, mode: 'overwrite' })` | Idempotent — same input → same final state. | -| `og.ingest({ data, mode: 'append' })` | **Not idempotent** — blind insert. Avoid for retry-prone callers. | +| `og.load({ data, mode: 'merge' })` (or the deprecated `og.ingest`) | **Idempotent** — use this mode for at-least-once pipelines. Requires `@key` constraints. | +| `og.load({ data, mode: 'overwrite' })` | Idempotent — same input → same final state. | +| `og.load({ data, mode: 'append' })` | **Not idempotent** — blind insert. Avoid for retry-prone callers. | | `og.mutate({ query })`, `og.change({ query })`, `og.change({ querySource })` | Depends on the query. `update X set ... where ...` is idempotent; `insert X { ... }` is idempotent only with `@unique` / `@key`. | If a mutation isn't naturally idempotent, fix the schema (add `@unique` or `@key`) — not the SDK. `og.read()` and `og.change()` are deprecated aliases of `og.query()` and `og.mutate()` (server 0.6.0). `og.change()` accepts both the old SDK fields (`querySource` / `queryName`) and the canonical mutation fields (`query` / `name`), then sends the canonical wire body. `og.read()` still uses `querySource` / `queryName`. -## Multi-graph clusters +## Cluster graphs -A single `omnigraph-server` can host multiple graphs side-by-side under `/graphs/{graphId}/...` (configured via `omnigraph.yaml`). The same `Omnigraph` class talks to either flavor — single-graph (the default) or multi-graph — by setting `graphId`: +`omnigraph-server` 0.7.0 is **cluster-only**: it hosts one or more graphs side-by-side under `/graphs/{graphId}/…`, declared in a `cluster.yaml`. Pick the graph with `graphId` (required for every graph-scoped call): ```ts const og = new Omnigraph({ @@ -223,7 +230,7 @@ await Promise.all([ ]); ``` -Don't fold the id into `baseUrl` (e.g. `http://host/graphs/alpha`) — that breaks the flat endpoints `og.health()` and `og.graphs.list()`, which the SDK intentionally never prefixes. +Don't fold the id into `baseUrl` (e.g. `http://host/graphs/alpha`) — that breaks the flat endpoints `og.health()` and `og.graphs.list()`, which the SDK intentionally never prefixes (and which are the only two methods that work without a `graphId`). ### Listing graphs @@ -232,12 +239,14 @@ const graphs = await og.graphs.list(); // → [{ graphId: 'alpha', uri: '…' }, { graphId: 'beta', uri: '…' }] ``` -`GET /graphs` exists only in multi-graph mode. On a single-graph server it returns 405, which the SDK surfaces as `MethodNotAllowedError`. When a token is configured, the server-level Cedar policy must authorize the `graph_list` action against `Omnigraph::Server::"root"` — without that grant, the call fails 403 even in multi-graph mode. +`GET /graphs` is the server-scoped management surface — it is **closed by default in every runtime state** (even unauthenticated). The cluster must apply a `cluster`-scoped Cedar bundle granting the `graph_list` action against `Omnigraph::Server::"root"`; without that grant the call fails 403. `og.health()` (`/healthz`) is the only always-open endpoint. + +### Auth (server 0.7.0, cluster-managed) -### Auth changes in server 0.6 +Authorization is Cedar policy declared in the cluster's `cluster.yaml` `policies:` section, with each bundle bound to scopes via `applies_to` (a graph id for per-graph rules, or the literal `cluster` for server-scoped `graph_list`). -- **Unauthenticated mode must be explicit on the server.** A server with no token configured no longer accepts arbitrary requests by default; the operator must enable unauthenticated mode in `omnigraph.yaml`. -- **Token without policy default-denies non-read actions.** If a token is configured but the Cedar policy doesn't grant a given action, the server returns 403 — including for actions that the 0.4-series treated as implicit reads. Pair every token with a policy that authorizes exactly the actions the SDK caller will issue (`read`, `export`, `change`, `schema_apply`, `branch_create`, `branch_delete`, `branch_merge`, `graph_list`, etc.). +- **Token without policy default-denies non-read actions.** If a token is configured but no bundle grants a given action, the server returns 403. Grant exactly the actions the SDK caller will issue: per-graph `read`, `export`, `change`, `schema_apply`, `branch_create`, `branch_delete`, `branch_merge`, `invoke_query`; and server-scoped `graph_list` for `og.graphs.list()`. +- **Unauthenticated (open) mode** must be explicit on the server (`--unauthenticated`). It opens the data plane but **not** the `graph_list` management surface, which always requires an explicit cluster policy bundle. ## Multiple clients in one process diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7c0ff94..8fa6d51 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@modernrelay/omnigraph", - "version": "0.6.1", + "version": "0.7.0", "description": "TypeScript SDK for the Omnigraph HTTP API.", "license": "MIT", "repository": { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index d2241dc..450e8ae 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -64,12 +64,14 @@ export interface OmnigraphOptions { /** Inject a custom fetch (testing, tracing, polyfills). */ fetch?: FetchLike; /** - * Target a specific graph in a multi-graph cluster. When set, every - * graph-scoped call is sent under `/graphs/${graphId}/...`. Flat paths - * (`/healthz`, `/graphs`) are never prefixed. + * Target a specific graph in the cluster. Every graph-scoped call is sent + * under `/graphs/${graphId}/...`. Flat paths (`/healthz`, `/graphs`) are + * never prefixed. * - * Leave undefined when talking to a single-graph server — the routes - * remain flat and behaviour is unchanged from earlier SDK versions. + * **Required** against omnigraph-server 0.7.0+ (cluster-only): a graph-scoped + * call without a `graphId` throws {@link ConfigurationError}. Only + * `og.health()` and `og.graphs.list()` work without one — use the latter to + * discover graph ids, then `og.graph(id)`. * * Don't fold the id into `baseUrl` (e.g. `http://host/graphs/alpha`): * that breaks `og.health()` and `og.graphs.list()`. Use this option, @@ -195,8 +197,26 @@ export default class Omnigraph { } /** - * Bulk-ingest NDJSON. **Use `mode: 'merge'` for at-least-once safety** — - * ensures retries upsert by `@key` instead of duplicating rows. + * Bulk-load NDJSON into a branch. Canonical write-load endpoint as of + * server 0.7.0 (successor to `ingest`). **Use `mode: 'merge'` for + * at-least-once safety** — retries upsert by `@key` instead of duplicating + * rows. + * + * **Branch creation is opt-in.** Without `from`, the target `branch` must + * already exist — a missing branch is a {@link NotFoundError} (404), never an + * implicit fork. Pass `from` to fork-if-missing. + */ + load(input: IngestInput, opts: CallOptions = {}): Promise { + return this.t.request('POST', '/load', { body: input, signal: opts.signal }); + } + + /** + * Bulk-ingest NDJSON. Identical request/response shape to {@link Omnigraph.load}. + * + * @deprecated Server 0.7.0 introduces {@link Omnigraph.load} as the canonical + * successor. `POST /ingest` still works (kept indefinitely as a shim) but the + * server emits `Deprecation: true` and `Link: ; rel="successor-version"` + * response headers. Migrate to `og.load()`; the shapes are identical. */ ingest(input: IngestInput, opts: CallOptions = {}): Promise { return this.t.request('POST', '/ingest', { body: input, signal: opts.signal }); diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 0a6912d..db8b67d 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -57,6 +57,17 @@ export class TooManyRequestsError extends OmnigraphError {} export class InternalServerError extends OmnigraphError {} export class NetworkError extends OmnigraphError {} +/** + * Thrown client-side, before any request is sent, when the client is + * misconfigured for the target server. As of omnigraph-server 0.7.0 the server + * is cluster-only: every graph-scoped operation is served under + * `/graphs/{graphId}/…`, so a `graphId` must be configured. Only `health()` + * and `graphs.list()` are graph-independent and work without one. + * + * `status` is 0 (no HTTP exchange occurred), like {@link NetworkError}. + */ +export class ConfigurationError extends OmnigraphError {} + const codeToClass: Record OmnigraphError> = { bad_request: BadRequestError, unauthorized: UnauthorizedError, diff --git a/packages/sdk/src/generated/index.ts b/packages/sdk/src/generated/index.ts index 67d6b15..5d2e26b 100644 --- a/packages/sdk/src/generated/index.ts +++ b/packages/sdk/src/generated/index.ts @@ -1,11 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts export { - type ApplySchemaData, - type ApplySchemaError, - type ApplySchemaErrors, - type ApplySchemaResponse, - type ApplySchemaResponses, type BranchCreateOutput, type BranchCreateRequest, type BranchDeleteOutput, @@ -13,120 +8,130 @@ export { BranchMergeOutcome, type BranchMergeOutput, type BranchMergeRequest, - type ChangeData, - type ChangeError, - type ChangeErrors, type ChangeOutput, type ChangeRequest, - type ChangeResponse, - type ChangeResponses, type ClientOptions, + type ClusterApplySchemaData, + type ClusterApplySchemaError, + type ClusterApplySchemaErrors, + type ClusterApplySchemaResponse, + type ClusterApplySchemaResponses, + type ClusterChangeData, + type ClusterChangeError, + type ClusterChangeErrors, + type ClusterChangeResponse, + type ClusterChangeResponses, + type ClusterCreateBranchData, + type ClusterCreateBranchError, + type ClusterCreateBranchErrors, + type ClusterCreateBranchResponse, + type ClusterCreateBranchResponses, + type ClusterDeleteBranchData, + type ClusterDeleteBranchError, + type ClusterDeleteBranchErrors, + type ClusterDeleteBranchResponse, + type ClusterDeleteBranchResponses, + type ClusterExportData, + type ClusterExportError, + type ClusterExportErrors, + type ClusterExportResponses, + type ClusterGetCommitData, + type ClusterGetCommitError, + type ClusterGetCommitErrors, + type ClusterGetCommitResponse, + type ClusterGetCommitResponses, + type ClusterGetSchemaData, + type ClusterGetSchemaError, + type ClusterGetSchemaErrors, + type ClusterGetSchemaResponse, + type ClusterGetSchemaResponses, + type ClusterGetSnapshotData, + type ClusterGetSnapshotError, + type ClusterGetSnapshotErrors, + type ClusterGetSnapshotResponse, + type ClusterGetSnapshotResponses, + type ClusterIngestData, + type ClusterIngestError, + type ClusterIngestErrors, + type ClusterIngestResponse, + type ClusterIngestResponses, + type ClusterInvokeQueryData, + type ClusterInvokeQueryError, + type ClusterInvokeQueryErrors, + type ClusterInvokeQueryResponse, + type ClusterInvokeQueryResponses, + type ClusterListBranchesData, + type ClusterListBranchesError, + type ClusterListBranchesErrors, + type ClusterListBranchesResponse, + type ClusterListBranchesResponses, + type ClusterListCommitsData, + type ClusterListCommitsError, + type ClusterListCommitsErrors, + type ClusterListCommitsResponse, + type ClusterListCommitsResponses, + type ClusterListQueriesData, + type ClusterListQueriesError, + type ClusterListQueriesErrors, + type ClusterListQueriesResponse, + type ClusterListQueriesResponses, + type ClusterLoadData, + type ClusterLoadError, + type ClusterLoadErrors, + type ClusterLoadResponse, + type ClusterLoadResponses, + type ClusterMergeBranchesData, + type ClusterMergeBranchesError, + type ClusterMergeBranchesErrors, + type ClusterMergeBranchesResponse, + type ClusterMergeBranchesResponses, + type ClusterMutateData, + type ClusterMutateError, + type ClusterMutateErrors, + type ClusterMutateResponse, + type ClusterMutateResponses, + type ClusterQueryData, + type ClusterQueryError, + type ClusterQueryErrors, + type ClusterQueryResponse, + type ClusterQueryResponses, + type ClusterReadData, + type ClusterReadError, + type ClusterReadErrors, + type ClusterReadResponse, + type ClusterReadResponses, type CommitListOutput, type CommitOutput, - type CreateBranchData, - type CreateBranchError, - type CreateBranchErrors, - type CreateBranchResponse, - type CreateBranchResponses, - type DeleteBranchData, - type DeleteBranchError, - type DeleteBranchErrors, - type DeleteBranchResponse, - type DeleteBranchResponses, ErrorCode, type ErrorOutput, - type ExportData, - type ExportError, - type ExportErrors, type ExportRequest, - type ExportResponses, - type GetCommitData, - type GetCommitError, - type GetCommitErrors, - type GetCommitResponse, - type GetCommitResponses, - type GetSchemaData, - type GetSchemaError, - type GetSchemaErrors, - type GetSchemaResponse, - type GetSchemaResponses, - type GetSnapshotData, - type GetSnapshotError, - type GetSnapshotErrors, - type GetSnapshotResponse, - type GetSnapshotResponses, type GraphInfo, type GraphListResponse, type HealthData, type HealthOutput, type HealthResponse, type HealthResponses, - type IngestData, - type IngestError, - type IngestErrors, type IngestOutput, type IngestRequest, - type IngestResponse, - type IngestResponses, type IngestTableOutput, - type InvokeQueryData, - type InvokeQueryError, - type InvokeQueryErrors, - type InvokeQueryResponse, - type InvokeQueryResponses, type InvokeStoredQueryRequest, type InvokeStoredQueryResponse, - type ListBranchesData, - type ListBranchesError, - type ListBranchesErrors, - type ListBranchesResponse, - type ListBranchesResponses, - type ListCommitsData, - type ListCommitsError, - type ListCommitsErrors, - type ListCommitsResponse, - type ListCommitsResponses, type ListGraphsData, type ListGraphsError, type ListGraphsErrors, type ListGraphsResponse, type ListGraphsResponses, - type ListQueriesData, - type ListQueriesError, - type ListQueriesErrors, - type ListQueriesResponse, - type ListQueriesResponses, LoadMode, type ManifestConflictOutput, - type MergeBranchesData, - type MergeBranchesError, - type MergeBranchesErrors, - type MergeBranchesResponse, - type MergeBranchesResponses, MergeConflictKindOutput, type MergeConflictOutput, - type MutateData, - type MutateError, - type MutateErrors, - type MutateResponse, - type MutateResponses, type ParamDescriptor, ParamKind, type QueriesCatalogOutput, type QueryCatalogEntry, - type QueryData, - type QueryError, - type QueryErrors, type QueryRequest, - type QueryResponse, - type QueryResponses, - type ReadData, - type ReadError, - type ReadErrors, type ReadOutput, type ReadRequest, - type ReadResponse, - type ReadResponses, type ReadTargetOutput, type SchemaApplyOutput, type SchemaApplyRequest, diff --git a/packages/sdk/src/generated/types.gen.ts b/packages/sdk/src/generated/types.gen.ts index d55ce05..77dc98b 100644 --- a/packages/sdk/src/generated/types.gen.ts +++ b/packages/sdk/src/generated/types.gen.ts @@ -171,7 +171,11 @@ export type HealthOutput = { export type IngestOutput = { actor_id?: string | null; - base_branch: string; + /** + * Base branch a fork was requested from (the request's `from`), echoed + * even when the branch already existed. `null` when `from` was absent. + */ + base_branch?: string | null; branch: string; branch_created: boolean; mode: LoadMode; @@ -181,7 +185,8 @@ export type IngestOutput = { export type IngestRequest = { /** - * Target branch. Created from `from` if it does not yet exist. Defaults to `main`. + * Target branch. Defaults to `main`. Without `from`, the branch must + * already exist — a missing branch is a 404, never an implicit fork. */ branch?: string | null; /** @@ -190,7 +195,9 @@ export type IngestRequest = { */ data: string; /** - * Parent branch used to create `branch` if it does not exist. Defaults to `main`. + * Parent branch used to create `branch` if it does not exist. Branch + * creation is opt-in by presence of this field; omit it to require an + * existing branch. */ from?: string | null; mode?: null | LoadMode; @@ -212,6 +219,14 @@ export type InvokeStoredQueryRequest = { * write targets this branch. */ branch?: string | null; + /** + * The kind the caller expects (RFC-011 Decision 3): `Some(false)` for + * `omnigraph query `, `Some(true)` for `omnigraph mutate `. + * When set and it disagrees with the stored query's actual kind, the + * server rejects the call (400) so the verb asserts the kind. `None` + * (the default) skips the check — preserving older clients and aliases. + */ + expect_mutation?: boolean | null; /** * JSON object whose keys match the stored query's declared parameters. */ @@ -471,14 +486,14 @@ export type SnapshotTableOutput = { table_version: number; }; -export type ListBranchesData = { +export type ListGraphsData = { body?: never; path?: never; query?: never; - url: "/branches"; + url: "/graphs"; }; -export type ListBranchesErrors = { +export type ListGraphsErrors = { /** * Unauthorized */ @@ -487,28 +502,72 @@ export type ListBranchesErrors = { * Forbidden */ 403: ErrorOutput; + /** + * Method not allowed (single-graph mode) + */ + 405: ErrorOutput; }; -export type ListBranchesError = ListBranchesErrors[keyof ListBranchesErrors]; +export type ListGraphsError = ListGraphsErrors[keyof ListGraphsErrors]; -export type ListBranchesResponses = { +export type ListGraphsResponses = { + /** + * List of registered graphs + */ + 200: GraphListResponse; +}; + +export type ListGraphsResponse = ListGraphsResponses[keyof ListGraphsResponses]; + +export type ClusterListBranchesData = { + body?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; + query?: never; + url: "/graphs/{graph_id}/branches"; +}; + +export type ClusterListBranchesErrors = { + /** + * Unauthorized + */ + 401: ErrorOutput; + /** + * Forbidden + */ + 403: ErrorOutput; +}; + +export type ClusterListBranchesError = + ClusterListBranchesErrors[keyof ClusterListBranchesErrors]; + +export type ClusterListBranchesResponses = { /** * List of branches */ 200: BranchListOutput; }; -export type ListBranchesResponse = - ListBranchesResponses[keyof ListBranchesResponses]; +export type ClusterListBranchesResponse = + ClusterListBranchesResponses[keyof ClusterListBranchesResponses]; -export type CreateBranchData = { +export type ClusterCreateBranchData = { body: BranchCreateRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/branches"; + url: "/graphs/{graph_id}/branches"; }; -export type CreateBranchErrors = { +export type ClusterCreateBranchErrors = { /** * Bad request */ @@ -531,26 +590,32 @@ export type CreateBranchErrors = { 429: ErrorOutput; }; -export type CreateBranchError = CreateBranchErrors[keyof CreateBranchErrors]; +export type ClusterCreateBranchError = + ClusterCreateBranchErrors[keyof ClusterCreateBranchErrors]; -export type CreateBranchResponses = { +export type ClusterCreateBranchResponses = { /** * Branch created */ 200: BranchCreateOutput; }; -export type CreateBranchResponse = - CreateBranchResponses[keyof CreateBranchResponses]; +export type ClusterCreateBranchResponse = + ClusterCreateBranchResponses[keyof ClusterCreateBranchResponses]; -export type MergeBranchesData = { +export type ClusterMergeBranchesData = { body: BranchMergeRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/branches/merge"; + url: "/graphs/{graph_id}/branches/merge"; }; -export type MergeBranchesErrors = { +export type ClusterMergeBranchesErrors = { /** * Bad request */ @@ -573,31 +638,36 @@ export type MergeBranchesErrors = { 429: ErrorOutput; }; -export type MergeBranchesError = MergeBranchesErrors[keyof MergeBranchesErrors]; +export type ClusterMergeBranchesError = + ClusterMergeBranchesErrors[keyof ClusterMergeBranchesErrors]; -export type MergeBranchesResponses = { +export type ClusterMergeBranchesResponses = { /** * Branches merged */ 200: BranchMergeOutput; }; -export type MergeBranchesResponse = - MergeBranchesResponses[keyof MergeBranchesResponses]; +export type ClusterMergeBranchesResponse = + ClusterMergeBranchesResponses[keyof ClusterMergeBranchesResponses]; -export type DeleteBranchData = { +export type ClusterDeleteBranchData = { body?: never; path: { + /** + * Graph id to route the request to. + */ + graph_id: string; /** * Branch name to delete */ branch: string; }; query?: never; - url: "/branches/{branch}"; + url: "/graphs/{graph_id}/branches/{branch}"; }; -export type DeleteBranchErrors = { +export type ClusterDeleteBranchErrors = { /** * Unauthorized */ @@ -616,26 +686,32 @@ export type DeleteBranchErrors = { 429: ErrorOutput; }; -export type DeleteBranchError = DeleteBranchErrors[keyof DeleteBranchErrors]; +export type ClusterDeleteBranchError = + ClusterDeleteBranchErrors[keyof ClusterDeleteBranchErrors]; -export type DeleteBranchResponses = { +export type ClusterDeleteBranchResponses = { /** * Branch deleted */ 200: BranchDeleteOutput; }; -export type DeleteBranchResponse = - DeleteBranchResponses[keyof DeleteBranchResponses]; +export type ClusterDeleteBranchResponse = + ClusterDeleteBranchResponses[keyof ClusterDeleteBranchResponses]; -export type ChangeData = { +export type ClusterChangeData = { body: ChangeRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/change"; + url: "/graphs/{graph_id}/change"; }; -export type ChangeErrors = { +export type ClusterChangeErrors = { /** * Bad request */ @@ -658,27 +734,33 @@ export type ChangeErrors = { 429: ErrorOutput; }; -export type ChangeError = ChangeErrors[keyof ChangeErrors]; +export type ClusterChangeError = ClusterChangeErrors[keyof ClusterChangeErrors]; -export type ChangeResponses = { +export type ClusterChangeResponses = { /** - * Mutation results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`) + * Mutation results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`) */ 200: ChangeOutput; }; -export type ChangeResponse = ChangeResponses[keyof ChangeResponses]; +export type ClusterChangeResponse = + ClusterChangeResponses[keyof ClusterChangeResponses]; -export type ListCommitsData = { +export type ClusterListCommitsData = { body?: never; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: { branch?: string | null; }; - url: "/commits"; + url: "/graphs/{graph_id}/commits"; }; -export type ListCommitsErrors = { +export type ClusterListCommitsErrors = { /** * Unauthorized */ @@ -689,31 +771,36 @@ export type ListCommitsErrors = { 403: ErrorOutput; }; -export type ListCommitsError = ListCommitsErrors[keyof ListCommitsErrors]; +export type ClusterListCommitsError = + ClusterListCommitsErrors[keyof ClusterListCommitsErrors]; -export type ListCommitsResponses = { +export type ClusterListCommitsResponses = { /** * List of commits */ 200: CommitListOutput; }; -export type ListCommitsResponse = - ListCommitsResponses[keyof ListCommitsResponses]; +export type ClusterListCommitsResponse = + ClusterListCommitsResponses[keyof ClusterListCommitsResponses]; -export type GetCommitData = { +export type ClusterGetCommitData = { body?: never; path: { + /** + * Graph id to route the request to. + */ + graph_id: string; /** * Commit identifier */ commit_id: string; }; query?: never; - url: "/commits/{commit_id}"; + url: "/graphs/{graph_id}/commits/{commit_id}"; }; -export type GetCommitErrors = { +export type ClusterGetCommitErrors = { /** * Unauthorized */ @@ -728,25 +815,32 @@ export type GetCommitErrors = { 404: ErrorOutput; }; -export type GetCommitError = GetCommitErrors[keyof GetCommitErrors]; +export type ClusterGetCommitError = + ClusterGetCommitErrors[keyof ClusterGetCommitErrors]; -export type GetCommitResponses = { +export type ClusterGetCommitResponses = { /** * Commit details */ 200: CommitOutput; }; -export type GetCommitResponse = GetCommitResponses[keyof GetCommitResponses]; +export type ClusterGetCommitResponse = + ClusterGetCommitResponses[keyof ClusterGetCommitResponses]; -export type ExportData = { +export type ClusterExportData = { body: ExportRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/export"; + url: "/graphs/{graph_id}/export"; }; -export type ExportErrors = { +export type ClusterExportErrors = { /** * Bad request */ @@ -761,23 +855,32 @@ export type ExportErrors = { 403: ErrorOutput; }; -export type ExportError = ExportErrors[keyof ExportErrors]; +export type ClusterExportError = ClusterExportErrors[keyof ClusterExportErrors]; -export type ExportResponses = { +export type ClusterExportResponses = { /** * Exported data as NDJSON */ 200: unknown; }; -export type ListGraphsData = { - body?: never; - path?: never; +export type ClusterIngestData = { + body: IngestRequest; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/graphs"; + url: "/graphs/{graph_id}/ingest"; }; -export type ListGraphsErrors = { +export type ClusterIngestErrors = { + /** + * Bad request + */ + 400: ErrorOutput; /** * Unauthorized */ @@ -787,46 +890,36 @@ export type ListGraphsErrors = { */ 403: ErrorOutput; /** - * Method not allowed (single-graph mode) - */ - 405: ErrorOutput; -}; - -export type ListGraphsError = ListGraphsErrors[keyof ListGraphsErrors]; - -export type ListGraphsResponses = { - /** - * List of registered graphs + * Per-actor admission cap exceeded; honor `Retry-After` header */ - 200: GraphListResponse; + 429: ErrorOutput; }; -export type ListGraphsResponse = ListGraphsResponses[keyof ListGraphsResponses]; +export type ClusterIngestError = ClusterIngestErrors[keyof ClusterIngestErrors]; -export type HealthData = { - body?: never; - path?: never; - query?: never; - url: "/healthz"; -}; - -export type HealthResponses = { +export type ClusterIngestResponses = { /** - * Server is healthy + * Load results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`) */ - 200: HealthOutput; + 200: IngestOutput; }; -export type HealthResponse = HealthResponses[keyof HealthResponses]; +export type ClusterIngestResponse = + ClusterIngestResponses[keyof ClusterIngestResponses]; -export type IngestData = { +export type ClusterLoadData = { body: IngestRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/ingest"; + url: "/graphs/{graph_id}/load"; }; -export type IngestErrors = { +export type ClusterLoadErrors = { /** * Bad request */ @@ -845,25 +938,31 @@ export type IngestErrors = { 429: ErrorOutput; }; -export type IngestError = IngestErrors[keyof IngestErrors]; +export type ClusterLoadError = ClusterLoadErrors[keyof ClusterLoadErrors]; -export type IngestResponses = { +export type ClusterLoadResponses = { /** - * Ingest results + * Load results */ 200: IngestOutput; }; -export type IngestResponse = IngestResponses[keyof IngestResponses]; +export type ClusterLoadResponse = + ClusterLoadResponses[keyof ClusterLoadResponses]; -export type MutateData = { +export type ClusterMutateData = { body: ChangeRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/mutate"; + url: "/graphs/{graph_id}/mutate"; }; -export type MutateErrors = { +export type ClusterMutateErrors = { /** * Bad request */ @@ -886,25 +985,31 @@ export type MutateErrors = { 429: ErrorOutput; }; -export type MutateError = MutateErrors[keyof MutateErrors]; +export type ClusterMutateError = ClusterMutateErrors[keyof ClusterMutateErrors]; -export type MutateResponses = { +export type ClusterMutateResponses = { /** * Mutation results */ 200: ChangeOutput; }; -export type MutateResponse = MutateResponses[keyof MutateResponses]; +export type ClusterMutateResponse = + ClusterMutateResponses[keyof ClusterMutateResponses]; -export type ListQueriesData = { +export type ClusterListQueriesData = { body?: never; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/queries"; + url: "/graphs/{graph_id}/queries"; }; -export type ListQueriesErrors = { +export type ClusterListQueriesErrors = { /** * Unauthorized */ @@ -915,31 +1020,36 @@ export type ListQueriesErrors = { 403: ErrorOutput; }; -export type ListQueriesError = ListQueriesErrors[keyof ListQueriesErrors]; +export type ClusterListQueriesError = + ClusterListQueriesErrors[keyof ClusterListQueriesErrors]; -export type ListQueriesResponses = { +export type ClusterListQueriesResponses = { /** * Stored-query catalog (the mcp.expose subset, with typed params) */ 200: QueriesCatalogOutput; }; -export type ListQueriesResponse = - ListQueriesResponses[keyof ListQueriesResponses]; +export type ClusterListQueriesResponse = + ClusterListQueriesResponses[keyof ClusterListQueriesResponses]; -export type InvokeQueryData = { +export type ClusterInvokeQueryData = { body?: null | InvokeStoredQueryRequest; path: { + /** + * Graph id to route the request to. + */ + graph_id: string; /** * Stored query name (the registry key) */ name: string; }; query?: never; - url: "/queries/{name}"; + url: "/graphs/{graph_id}/queries/{name}"; }; -export type InvokeQueryErrors = { +export type ClusterInvokeQueryErrors = { /** * Bad request (param type error; snapshot on a stored mutation) */ @@ -970,26 +1080,32 @@ export type InvokeQueryErrors = { 500: ErrorOutput; }; -export type InvokeQueryError = InvokeQueryErrors[keyof InvokeQueryErrors]; +export type ClusterInvokeQueryError = + ClusterInvokeQueryErrors[keyof ClusterInvokeQueryErrors]; -export type InvokeQueryResponses = { +export type ClusterInvokeQueryResponses = { /** * Read envelope (ReadOutput) or mutation envelope (ChangeOutput), serialized untagged */ 200: InvokeStoredQueryResponse; }; -export type InvokeQueryResponse = - InvokeQueryResponses[keyof InvokeQueryResponses]; +export type ClusterInvokeQueryResponse = + ClusterInvokeQueryResponses[keyof ClusterInvokeQueryResponses]; -export type QueryData = { +export type ClusterQueryData = { body: QueryRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/query"; + url: "/graphs/{graph_id}/query"; }; -export type QueryErrors = { +export type ClusterQueryErrors = { /** * Bad request - also returned when the query body contains mutations; use POST /mutate (or its deprecated alias POST /change) for write queries */ @@ -1004,25 +1120,31 @@ export type QueryErrors = { 403: ErrorOutput; }; -export type QueryError = QueryErrors[keyof QueryErrors]; +export type ClusterQueryError = ClusterQueryErrors[keyof ClusterQueryErrors]; -export type QueryResponses = { +export type ClusterQueryResponses = { /** * Query results */ 200: ReadOutput; }; -export type QueryResponse = QueryResponses[keyof QueryResponses]; +export type ClusterQueryResponse = + ClusterQueryResponses[keyof ClusterQueryResponses]; -export type ReadData = { +export type ClusterReadData = { body: ReadRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/read"; + url: "/graphs/{graph_id}/read"; }; -export type ReadErrors = { +export type ClusterReadErrors = { /** * Bad request */ @@ -1037,25 +1159,31 @@ export type ReadErrors = { 403: ErrorOutput; }; -export type ReadError = ReadErrors[keyof ReadErrors]; +export type ClusterReadError = ClusterReadErrors[keyof ClusterReadErrors]; -export type ReadResponses = { +export type ClusterReadResponses = { /** - * Query results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`) + * Query results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`) */ 200: ReadOutput; }; -export type ReadResponse = ReadResponses[keyof ReadResponses]; +export type ClusterReadResponse = + ClusterReadResponses[keyof ClusterReadResponses]; -export type GetSchemaData = { +export type ClusterGetSchemaData = { body?: never; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/schema"; + url: "/graphs/{graph_id}/schema"; }; -export type GetSchemaErrors = { +export type ClusterGetSchemaErrors = { /** * Unauthorized */ @@ -1066,25 +1194,32 @@ export type GetSchemaErrors = { 403: ErrorOutput; }; -export type GetSchemaError = GetSchemaErrors[keyof GetSchemaErrors]; +export type ClusterGetSchemaError = + ClusterGetSchemaErrors[keyof ClusterGetSchemaErrors]; -export type GetSchemaResponses = { +export type ClusterGetSchemaResponses = { /** * Current schema source */ 200: SchemaOutput; }; -export type GetSchemaResponse = GetSchemaResponses[keyof GetSchemaResponses]; +export type ClusterGetSchemaResponse = + ClusterGetSchemaResponses[keyof ClusterGetSchemaResponses]; -export type ApplySchemaData = { +export type ClusterApplySchemaData = { body: SchemaApplyRequest; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: never; - url: "/schema/apply"; + url: "/graphs/{graph_id}/schema/apply"; }; -export type ApplySchemaErrors = { +export type ClusterApplySchemaErrors = { /** * Bad request */ @@ -1097,34 +1232,44 @@ export type ApplySchemaErrors = { * Forbidden */ 403: ErrorOutput; + /** + * Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart + */ + 409: ErrorOutput; /** * Per-actor admission cap exceeded; honor `Retry-After` header */ 429: ErrorOutput; }; -export type ApplySchemaError = ApplySchemaErrors[keyof ApplySchemaErrors]; +export type ClusterApplySchemaError = + ClusterApplySchemaErrors[keyof ClusterApplySchemaErrors]; -export type ApplySchemaResponses = { +export type ClusterApplySchemaResponses = { /** * Schema apply results */ 200: SchemaApplyOutput; }; -export type ApplySchemaResponse = - ApplySchemaResponses[keyof ApplySchemaResponses]; +export type ClusterApplySchemaResponse = + ClusterApplySchemaResponses[keyof ClusterApplySchemaResponses]; -export type GetSnapshotData = { +export type ClusterGetSnapshotData = { body?: never; - path?: never; + path: { + /** + * Graph id to route the request to. + */ + graph_id: string; + }; query?: { branch?: string | null; }; - url: "/snapshot"; + url: "/graphs/{graph_id}/snapshot"; }; -export type GetSnapshotErrors = { +export type ClusterGetSnapshotErrors = { /** * Unauthorized */ @@ -1135,14 +1280,31 @@ export type GetSnapshotErrors = { 403: ErrorOutput; }; -export type GetSnapshotError = GetSnapshotErrors[keyof GetSnapshotErrors]; +export type ClusterGetSnapshotError = + ClusterGetSnapshotErrors[keyof ClusterGetSnapshotErrors]; -export type GetSnapshotResponses = { +export type ClusterGetSnapshotResponses = { /** * Database snapshot */ 200: SnapshotOutput; }; -export type GetSnapshotResponse = - GetSnapshotResponses[keyof GetSnapshotResponses]; +export type ClusterGetSnapshotResponse = + ClusterGetSnapshotResponses[keyof ClusterGetSnapshotResponses]; + +export type HealthData = { + body?: never; + path?: never; + query?: never; + url: "/healthz"; +}; + +export type HealthResponses = { + /** + * Server is healthy + */ + 200: HealthOutput; +}; + +export type HealthResponse = HealthResponses[keyof HealthResponses]; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f11b3b1..2121b19 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -23,6 +23,7 @@ export { TooManyRequestsError, InternalServerError, NetworkError, + ConfigurationError, } from './errors'; // Public DTO types (camelCase). Inputs end in `Input`; outputs are bare nouns. diff --git a/packages/sdk/src/resources/graphs.ts b/packages/sdk/src/resources/graphs.ts index 3d6f954..66816db 100644 --- a/packages/sdk/src/resources/graphs.ts +++ b/packages/sdk/src/resources/graphs.ts @@ -6,11 +6,14 @@ export class GraphsResource { constructor(private readonly t: Transport) {} /** - * List every graph registered with the server, alphabetically by `graphId`. + * List every graph registered with the cluster, alphabetically by `graphId`. * - * Multi-graph mode only. On a single-graph server this call returns 405 → - * `MethodNotAllowedError`. When a token is configured the server-level - * Cedar policy must authorize the `graph_list` action. + * `/graphs` is the server-scoped management surface, **closed by default in + * every runtime state** (even unauthenticated). The cluster must apply a + * `cluster`-scoped Cedar bundle granting the `graph_list` action against + * `Omnigraph::Server::"root"`; without that grant this call fails 403 → + * `ForbiddenError`. This and `health()` are the only methods that work + * without a configured `graphId`. * * Routing note: `/graphs` is a flat management endpoint and is **never** * rewritten under a `graphId` prefix. diff --git a/packages/sdk/src/resources/queries.ts b/packages/sdk/src/resources/queries.ts index 9e4bd72..072d9ab 100644 --- a/packages/sdk/src/resources/queries.ts +++ b/packages/sdk/src/resources/queries.ts @@ -33,6 +33,10 @@ export class QueriesResource { * * A `name` the caller lacks `invoke_query` for is indistinguishable from an * unknown one — both surface as `NotFoundError`. + * + * Pass `expectMutation: true` (or `false`) to assert the stored query's kind + * (server 0.7.0+): the server rejects a mismatch with `BadRequestError`. + * Omit it to skip the check. */ invoke( name: string, diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts index cfe999c..cc88928 100644 --- a/packages/sdk/src/transport.ts +++ b/packages/sdk/src/transport.ts @@ -1,5 +1,5 @@ import { camelToSnake, snakeToCamel } from './case'; -import { fromResponse, NetworkError } from './errors'; +import { ConfigurationError, fromResponse, NetworkError } from './errors'; export type FetchLike = ( input: string | URL | Request, @@ -11,9 +11,13 @@ export interface TransportOptions { token?: string; fetch?: FetchLike; /** - * Cluster-mode graph id. When set, all graph-scoped paths are rewritten to + * Cluster graph id. All graph-scoped paths are rewritten to * `/graphs/${encodeURIComponent(graphId)}${path}` before being sent. Flat * management paths (`/healthz`, `/graphs`) are exempt. + * + * Required when targeting omnigraph-server 0.7.0+ (cluster-only): a + * graph-scoped request issued without a `graphId` throws + * {@link ConfigurationError} before hitting the network. */ graphId?: string; } @@ -90,6 +94,20 @@ export class Transport { path: string, opts: RequestOptions, ): Promise { + // Cluster-only servers (0.7.0+) serve every graph-scoped operation under + // `/graphs/{graphId}/…`. Refuse a graph-scoped call without a configured + // graphId here, with an actionable message, instead of letting it hit a + // non-existent flat route and surface as an opaque 404. + if (!this.graphId && !FLAT_PATHS.has(path)) { + throw new ConfigurationError({ + status: 0, + message: + `graphId is required for graph-scoped operations on omnigraph-server 0.7.0+ ` + + `(attempted ${method} ${path}). Pass { graphId } to new Omnigraph(...) or use ` + + `og.graph(id). Only health() and graphs.list() work without one.`, + request: { method, url: this.baseUrl + path }, + }); + } const url = this.buildUrl(path, opts.query); const headers = new Headers(); if (this.token) headers.set('Authorization', `Bearer ${this.token}`); diff --git a/packages/sdk/src/version.gen.ts b/packages/sdk/src/version.gen.ts index 327b92d..cb483e6 100644 --- a/packages/sdk/src/version.gen.ts +++ b/packages/sdk/src/version.gen.ts @@ -6,4 +6,4 @@ * The SDK targets the corresponding OpenAPI spec exactly; behaviour against * a different server major.minor is undefined. */ -export const SERVER_VERSION = "0.6.1"; +export const SERVER_VERSION = "0.7.0"; diff --git a/packages/sdk/test/branches.test.ts b/packages/sdk/test/branches.test.ts index 05c9a98..09d4ad2 100644 --- a/packages/sdk/test/branches.test.ts +++ b/packages/sdk/test/branches.test.ts @@ -5,11 +5,11 @@ import { stubFetch } from './helpers'; describe('branches resource', () => { it('list returns string array, sends GET /branches', async () => { const { fetch, calls } = stubFetch({ body: { branches: ['main', 'feature'] } }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const result = await og.branches.list(); expect(result).toEqual(['main', 'feature']); expect(calls[0]?.method).toBe('GET'); - expect(calls[0]?.url).toBe('http://x/branches'); + expect(calls[0]?.url).toBe('http://x/graphs/g/branches'); }); it('create sends POST with snake_case body', async () => { @@ -21,10 +21,10 @@ describe('branches resource', () => { uri: 's3://bucket/repo', }, }); - const og = new Omnigraph({ baseUrl: 'http://x', token: 't', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', token: 't', graphId: 'g', fetch }); const r = await og.branches.create({ name: 'feature', from: 'main' }); expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/branches'); + expect(calls[0]?.url).toBe('http://x/graphs/g/branches'); expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ name: 'feature', from: 'main' }); expect(calls[0]?.headers['authorization']).toBe('Bearer t'); expect(r.name).toBe('feature'); @@ -39,7 +39,7 @@ describe('branches resource', () => { target: 'main', }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.branches.merge({ source: 'feature', target: 'main' }); expect(r.outcome).toBe('fast_forward'); }); @@ -48,10 +48,10 @@ describe('branches resource', () => { const { fetch, calls } = stubFetch({ body: { actor_id: null, name: 'a b/c', uri: 's3://x' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.branches.delete('a b/c'); expect(calls[0]?.method).toBe('DELETE'); - expect(calls[0]?.url).toBe('http://x/branches/a%20b%2Fc'); + expect(calls[0]?.url).toBe('http://x/graphs/g/branches/a%20b%2Fc'); }); it('throws ConflictError on 409', async () => { @@ -59,7 +59,7 @@ describe('branches resource', () => { status: 409, body: { error: 'branch exists', code: 'conflict' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await expect(og.branches.create({ name: 'main' })).rejects.toBeInstanceOf(ConflictError); }); @@ -69,7 +69,7 @@ describe('branches resource', () => { body: { error: 'not found', code: 'not_found' }, headers: { 'X-Request-Id': '01ABC' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); try { await og.branches.delete('nonexistent'); throw new Error('should have thrown'); diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts index 1a7ba10..fb51c0e 100644 --- a/packages/sdk/test/client.test.ts +++ b/packages/sdk/test/client.test.ts @@ -17,10 +17,10 @@ describe('top-level client operations', () => { const { fetch, calls } = stubFetch({ body: { branch: 'main', tables: [], snapshot_id: 'snap-1' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const s = await og.snapshot({ branch: 'main' }); expect(calls[0]?.method).toBe('GET'); - expect(calls[0]?.url).toBe('http://x/snapshot?branch=main'); + expect(calls[0]?.url).toBe('http://x/graphs/g/snapshot?branch=main'); expect(s.branch).toBe('main'); }); @@ -28,9 +28,9 @@ describe('top-level client operations', () => { const { fetch, calls } = stubFetch({ body: { branch: 'main', tables: [], snapshot_id: 'snap-1' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.snapshot(); - expect(calls[0]?.url).toBe('http://x/snapshot'); + expect(calls[0]?.url).toBe('http://x/graphs/g/snapshot'); }); it('ingest sends NDJSON data via JSON body and camelCases the response', async () => { @@ -47,7 +47,7 @@ describe('top-level client operations', () => { uri: 's3://x', }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.ingest({ branch: 'feat', from: 'main', @@ -55,7 +55,7 @@ describe('top-level client operations', () => { data: '{"type":"Person","data":{"name":"A"}}\n', }); expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/ingest'); + expect(calls[0]?.url).toBe('http://x/graphs/g/ingest'); const body = JSON.parse(calls[0]?.body ?? '{}'); expect(body.branch).toBe('feat'); expect(body.from).toBe('main'); @@ -65,6 +65,30 @@ describe('top-level client operations', () => { expect(r.tables[0]?.tableKey).toBe('node:Person'); expect(r.tables[0]?.rowsLoaded).toBe(2); }); + + it('load sends POST /load and tolerates a null base_branch', async () => { + const { fetch, calls } = stubFetch({ + body: { + actor_id: null, + base_branch: null, + branch: 'main', + branch_created: false, + mode: 'merge', + tables: [], + uri: 's3://x', + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + const r = await og.load({ + branch: 'main', + mode: 'merge', + data: '{"type":"Person","data":{"name":"A"}}\n', + }); + expect(calls[0]?.method).toBe('POST'); + expect(calls[0]?.url).toBe('http://x/graphs/g/load'); + expect(r.baseBranch).toBeNull(); + expect(r.branchCreated).toBe(false); + }); }); describe('og.query and og.mutate (canonical successors to read/change)', () => { @@ -78,7 +102,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { rows: [{ '$p.name': 'Alice' }], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.query({ query: 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }', name: 'find', @@ -86,7 +110,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { branch: 'main', }); expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/query'); + expect(calls[0]?.url).toBe('http://x/graphs/g/query'); const body = JSON.parse(calls[0]?.body ?? '{}'); expect(body.query).toContain('match'); expect(body.name).toBe('find'); @@ -100,7 +124,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { const { fetch, calls } = stubFetch({ body: { query_name: 'q', target: { branch: 'main', snapshot: null }, row_count: 0, columns: [], rows: [] }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.query({ query: 'query q($keyName: String) { match { $u: User { id: $keyName } } return { $u.name } }', params: { keyName: 'value', $internal: 1 }, @@ -119,7 +143,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { query_name: 'addPerson', }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.mutate({ query: 'query addPerson($name: String) { insert Person { name: $name } }', name: 'addPerson', @@ -127,7 +151,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { branch: 'feature', }); expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/mutate'); + expect(calls[0]?.url).toBe('http://x/graphs/g/mutate'); const body = JSON.parse(calls[0]?.body ?? '{}'); expect(body.query).toContain('insert Person'); expect(body.name).toBe('addPerson'); @@ -140,7 +164,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { const { fetch, calls } = stubFetch({ body: { actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.change({ query: 'query q() { insert X {} }', name: 'q', branch: 'main' }); const body = JSON.parse(calls[0]?.body ?? '{}'); expect(body.query).toBe('query q() { insert X {} }'); @@ -153,14 +177,14 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { const { fetch, calls } = stubFetch({ body: { actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.change({ querySource: 'query q() { insert X {} }', queryName: 'q', branch: 'main', }); const body = JSON.parse(calls[0]?.body ?? '{}'); - expect(calls[0]?.url).toBe('http://x/change'); + expect(calls[0]?.url).toBe('http://x/graphs/g/change'); expect(body.query).toBe('query q() { insert X {} }'); expect(body.name).toBe('q'); expect('query_source' in body).toBe(false); @@ -171,7 +195,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { const { fetch } = stubFetch({ body: { actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); expect(() => og.change({ query: 'query q() { insert X {} }', @@ -187,13 +211,13 @@ describe('og.graph(id)', () => { { body: { branches: [] } }, { body: { branches: [] } }, ]); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'orig', fetch }); const scoped = og.graph('alpha'); expect(scoped).not.toBe(og); await scoped.branches.list(); await og.branches.list(); expect(calls[0]?.url).toBe('http://x/graphs/alpha/branches'); - expect(calls[1]?.url).toBe('http://x/branches'); + expect(calls[1]?.url).toBe('http://x/graphs/orig/branches'); }); it('inherits token and fetch from the parent client', async () => { @@ -222,7 +246,7 @@ describe('export streaming options', () => { body: ndjson, headers: { 'content-type': 'application/x-ndjson' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const rows: PersonRow[] = []; for await (const row of og.export({ branch: 'main' })) { rows.push(row); @@ -256,6 +280,7 @@ describe('export streaming options', () => { }; const og = new Omnigraph({ baseUrl: 'http://x', + graphId: 'g', fetch: abortablefetch as unknown as typeof globalThis.fetch, }); const rows: unknown[] = []; diff --git a/packages/sdk/test/commits.test.ts b/packages/sdk/test/commits.test.ts index 4ad00ae..ee0881d 100644 --- a/packages/sdk/test/commits.test.ts +++ b/packages/sdk/test/commits.test.ts @@ -19,10 +19,10 @@ describe('commits resource', () => { ], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const result = await og.commits.list({ branch: 'main' }); expect(calls[0]?.method).toBe('GET'); - expect(calls[0]?.url).toBe('http://x/commits?branch=main'); + expect(calls[0]?.url).toBe('http://x/graphs/g/commits?branch=main'); expect(Array.isArray(result)).toBe(true); expect(result).toHaveLength(1); expect(result[0]?.graphCommitId).toBe('01KQ'); @@ -32,9 +32,9 @@ describe('commits resource', () => { it('list omits branch query param when not given', async () => { const { fetch, calls } = stubFetch({ body: { commits: [] } }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.commits.list(); - expect(calls[0]?.url).toBe('http://x/commits'); + expect(calls[0]?.url).toBe('http://x/graphs/g/commits'); }); it('retrieve sends GET /commits/{id} with URL-escaped id', async () => { @@ -49,10 +49,10 @@ describe('commits resource', () => { created_at: 1, }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.commits.retrieve('01KQ/X'); expect(calls[0]?.method).toBe('GET'); - expect(calls[0]?.url).toBe('http://x/commits/01KQ%2FX'); + expect(calls[0]?.url).toBe('http://x/graphs/g/commits/01KQ%2FX'); expect(r.graphCommitId).toBe('01KQ/X'); }); @@ -62,7 +62,7 @@ describe('commits resource', () => { body: { error: 'commit not found', code: 'not_found' }, headers: { 'X-Request-Id': '01XYZ' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); try { await og.commits.retrieve('01BOGUS'); throw new Error('should have thrown'); diff --git a/packages/sdk/test/e2e.test.ts b/packages/sdk/test/e2e.test.ts index b11c0e0..c148aa9 100644 --- a/packages/sdk/test/e2e.test.ts +++ b/packages/sdk/test/e2e.test.ts @@ -1,13 +1,31 @@ -// End-to-end tests against a real omnigraph-server. +// End-to-end tests against a real omnigraph-server (cluster-only, 0.7.0+). // -// Skipped unless OMNIGRAPH_E2E=1. Local quick-start: +// Skipped unless OMNIGRAPH_E2E=1. Local quick-start (a local-filesystem +// cluster serving two graphs, alpha + beta): // -// tmp=$(mktemp -d) -// omnigraph init --schema packages/sdk/test/fixtures/schema.pg "$tmp/repo.omni" -// omnigraph load --data packages/sdk/test/fixtures/data.jsonl --mode overwrite "$tmp/repo.omni" -// OMNIGRAPH_SERVER_BEARER_TOKEN=ci-token omnigraph-server "$tmp/repo.omni" --bind 127.0.0.1:18080 & +// dir=$(mktemp -d) +// cp packages/sdk/test/fixtures/schema.pg "$dir/graph.pg" +// cat > "$dir/cluster.yaml" <<'YAML' +// version: 1 +// metadata: { name: e2e } +// state: { backend: cluster, lock: true } +// graphs: +// alpha: { schema: ./graph.pg } +// beta: { schema: ./graph.pg } +// policies: +// server: { file: ./server.policy.yaml, applies_to: [cluster] } +// data: { file: ./graph.policy.yaml, applies_to: [alpha, beta] } +// YAML +// # server.policy.yaml grants `graph_list`; graph.policy.yaml grants the +// # per-graph data actions (read/export/change/schema_apply/branch_*). +// omnigraph cluster import --config "$dir" +// omnigraph cluster apply --config "$dir" +// for g in alpha beta; do +// omnigraph load --data packages/sdk/test/fixtures/data.jsonl --mode overwrite "$dir/graphs/$g.omni" +// done +// OMNIGRAPH_SERVER_BEARER_TOKEN=ci-token omnigraph-server --cluster "$dir" --bind 127.0.0.1:18080 & // OMNIGRAPH_E2E=1 OMNIGRAPH_BASE_URL=http://127.0.0.1:18080 OMNIGRAPH_TOKEN=ci-token \ -// pnpm --filter @modernrelay/omnigraph run test +// OMNIGRAPH_GRAPH_ID=alpha pnpm --filter @modernrelay/omnigraph run test // // CI runs this in `.github/workflows/e2e.yml` against the omnigraph-server // release pinned by `omnigraph.serverVersion` in the repo-root package.json. @@ -17,14 +35,12 @@ import Omnigraph, { BadRequestError, BranchMergeOutcome, LoadMode, - MethodNotAllowedError, NotFoundError, SERVER_VERSION, UnauthorizedError, } from '../src'; const E2E_ENABLED = process.env.OMNIGRAPH_E2E === '1'; -const E2E_MULTIGRAPH = process.env.OMNIGRAPH_E2E_MULTIGRAPH === '1'; const BASE_URL = process.env.OMNIGRAPH_BASE_URL ?? 'http://127.0.0.1:18080'; const TOKEN = process.env.OMNIGRAPH_TOKEN; const GRAPH_ID = process.env.OMNIGRAPH_GRAPH_ID; @@ -36,6 +52,11 @@ let og: Omnigraph; describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { beforeAll(() => { + // 0.7.0 is cluster-only: graph-scoped ops require a graphId. Fail loud + // here rather than letting every test throw ConfigurationError. + if (!GRAPH_ID) { + throw new Error('OMNIGRAPH_E2E=1 requires OMNIGRAPH_GRAPH_ID (cluster-only server)'); + } og = new Omnigraph({ baseUrl: BASE_URL, token: TOKEN, graphId: GRAPH_ID }); }); @@ -65,6 +86,22 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { }); }); + describe('graphs registry + cluster routing', () => { + it('graphs.list returns the configured graph (and beta)', async () => { + const graphs = await og.graphs.list(); + const ids = graphs.map((g) => g.graphId); + expect(ids).toContain(GRAPH_ID); + expect(ids).toContain('beta'); + }); + + it('og.graph("beta") routes under /graphs/beta and returns a snapshot', async () => { + const beta = og.graph('beta'); + const s = await beta.snapshot({ branch: 'main' }); + expect(s.branch).toBe('main'); + expect(s.tables.length).toBeGreaterThan(0); + }); + }); + describe('snapshot', () => { it('GET /snapshot?branch=main returns tables with row counts', async () => { const s = await og.snapshot({ branch: 'main' }); @@ -154,8 +191,33 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { }); }); - describe('ingest', () => { - it('merge mode creates branch and writes rows', async () => { + describe('load / ingest', () => { + // `from` is mandatory for a missing branch under 0.7.0 — without it the + // server returns 404 (no implicit fork). Both tests pass `from: 'main'`. + it('load (canonical) merge-mode forks a branch and writes rows', async () => { + const branch = `e2e-load-${Date.now()}`; + const name = `e2e-Carol-${Date.now()}`; + branchesToCleanup.push(branch); + const result = await og.load({ + branch, + from: 'main', + mode: LoadMode.MERGE, + data: JSON.stringify({ type: 'Person', data: { name, age: 33 } }) + '\n', + }); + expect(result.branch).toBe(branch); + expect(result.tables.length).toBeGreaterThan(0); + + const r = await og.read({ + querySource: + 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }', + queryName: 'find', + params: { name }, + branch, + }); + expect((r.rows as unknown[]).length).toBe(1); + }); + + it('ingest (deprecated alias) merge mode creates branch and writes rows', async () => { const branch = `e2e-ingest-${Date.now()}`; const dianaName = `e2e-Diana-${Date.now()}`; branchesToCleanup.push(branch); @@ -235,60 +297,4 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { ).rejects.toBeInstanceOf(BadRequestError); }); }); - - // Single-graph servers return 405 on /graphs; multi-graph servers don't run - // this block (see the multi-graph describe further down). - describe.skipIf(E2E_MULTIGRAPH)('graphs.list (single-graph)', () => { - it('throws MethodNotAllowedError', async () => { - await expect(og.graphs.list()).rejects.toBeInstanceOf(MethodNotAllowedError); - }); - }); -}); - -// Multi-graph mode: an `omnigraph-server --config omnigraph.yaml` with a -// non-empty `graphs:` map (e.g. alpha + beta). Requires both: -// OMNIGRAPH_E2E=1 OMNIGRAPH_E2E_MULTIGRAPH=1 -// OMNIGRAPH_BASE_URL, OMNIGRAPH_TOKEN, OMNIGRAPH_GRAPH_ID (= "alpha") -// and a server-level Cedar policy granting the test actor `graph_list` plus -// per-graph policies granting the per-graph actions exercised below. -describe.skipIf(!E2E_ENABLED || !E2E_MULTIGRAPH)('e2e multigraph: cluster routing', () => { - let mg: Omnigraph; - - beforeAll(() => { - if (!GRAPH_ID) { - throw new Error('OMNIGRAPH_E2E_MULTIGRAPH=1 requires OMNIGRAPH_GRAPH_ID'); - } - mg = new Omnigraph({ baseUrl: BASE_URL, token: TOKEN, graphId: GRAPH_ID }); - }); - - it('graphs.list returns alpha and beta', async () => { - const graphs = await mg.graphs.list(); - const ids = graphs.map((g) => g.graphId).sort(); - expect(ids).toContain('alpha'); - expect(ids).toContain('beta'); - }); - - it('snapshot on the configured graph returns tables', async () => { - const s = await mg.snapshot({ branch: 'main' }); - expect(s.branch).toBe('main'); - expect(s.tables.length).toBeGreaterThan(0); - }); - - it('read on the configured graph returns the expected row', async () => { - const r = await mg.read({ - querySource: - 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }', - queryName: 'find', - params: { name: 'Alice' }, - branch: 'main', - }); - expect((r.rows as unknown[]).length).toBe(1); - }); - - it('og.graph("beta") routes under /graphs/beta and returns a snapshot', async () => { - const beta = mg.graph('beta'); - const s = await beta.snapshot({ branch: 'main' }); - expect(s.branch).toBe('main'); - expect(s.tables.length).toBeGreaterThan(0); - }); }); diff --git a/packages/sdk/test/enums.test.ts b/packages/sdk/test/enums.test.ts index ffbe21f..7955d82 100644 --- a/packages/sdk/test/enums.test.ts +++ b/packages/sdk/test/enums.test.ts @@ -50,7 +50,7 @@ describe('runtime enum constants', () => { uri: 's3://x', }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); // Mode accepts LoadModeType; LoadMode.MERGE is the value. const mode: LoadModeType = LoadMode.MERGE; await og.ingest({ branch: 'feat', data: '{}\n', mode }); diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index 4e060ca..c834ce0 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -52,7 +52,7 @@ describe('error dispatcher', () => { manifest_conflict: { actual: 7, expected: 5, table_key: 'Person' }, }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); try { await og.change({ query: 'insert Person { name: "x" }' }); throw new Error('should have thrown'); @@ -79,7 +79,7 @@ describe('error dispatcher', () => { ], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); try { await og.branches.merge({ source: 'a', target: 'b' }); throw new Error('should have thrown'); diff --git a/packages/sdk/test/graphs.test.ts b/packages/sdk/test/graphs.test.ts index 84c06cd..607e9a1 100644 --- a/packages/sdk/test/graphs.test.ts +++ b/packages/sdk/test/graphs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import Omnigraph, { MethodNotAllowedError } from '../src'; +import Omnigraph, { ForbiddenError } from '../src'; import { stubFetch } from './helpers'; describe('graphs resource', () => { @@ -29,13 +29,13 @@ describe('graphs resource', () => { expect(calls[0]?.url).toBe('http://x/graphs'); }); - it('throws MethodNotAllowedError when single-graph server returns 405', async () => { + it('throws ForbiddenError when graph_list is not granted (closed-by-default)', async () => { const { fetch } = stubFetch({ - status: 405, - body: { error: 'method not allowed', code: 'method_not_allowed' }, + status: 403, + body: { error: 'graph_list not authorized', code: 'forbidden' }, }); const og = new Omnigraph({ baseUrl: 'http://x', fetch }); - await expect(og.graphs.list()).rejects.toBeInstanceOf(MethodNotAllowedError); + await expect(og.graphs.list()).rejects.toBeInstanceOf(ForbiddenError); }); it('attaches Authorization header when token is set', async () => { diff --git a/packages/sdk/test/opaque.test.ts b/packages/sdk/test/opaque.test.ts index 008c73c..fbecdd0 100644 --- a/packages/sdk/test/opaque.test.ts +++ b/packages/sdk/test/opaque.test.ts @@ -33,7 +33,7 @@ describe('opaque keys (GQ params, rows, columns)', () => { rows: [], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.read({ branch: 'main', querySource: 'query q($userId: I32) { match { $u: User { id: $userId } } return { $u.name } }', @@ -53,7 +53,7 @@ describe('opaque keys (GQ params, rows, columns)', () => { query_name: 'm', }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.change({ branch: 'feat', query: 'mutation m($keyName: String) { ... }', @@ -76,7 +76,7 @@ describe('opaque keys (GQ params, rows, columns)', () => { ], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.read({ branch: 'main', querySource: 'query q() { ... }' }); expect(r.queryName).toBe('q'); // top-level keys still camelCased expect(r.rowCount).toBe(2); diff --git a/packages/sdk/test/queries.test.ts b/packages/sdk/test/queries.test.ts index 9f5bb54..f3d5cbb 100644 --- a/packages/sdk/test/queries.test.ts +++ b/packages/sdk/test/queries.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import Omnigraph from '../src'; +import Omnigraph, { BadRequestError } from '../src'; import { stubFetch } from './helpers'; describe('queries resource (stored queries)', () => { @@ -20,10 +20,10 @@ describe('queries resource (stored queries)', () => { ], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.queries.list(); expect(calls[0]?.method).toBe('GET'); - expect(calls[0]?.url).toBe('http://x/queries'); + expect(calls[0]?.url).toBe('http://x/graphs/g/queries'); expect(r.queries[0]?.toolName).toBe('find_inactive'); expect(r.queries[0]?.mutation).toBe(false); // Typed-catalog fields are camelized (not opaque). @@ -39,13 +39,13 @@ describe('queries resource (stored queries)', () => { rows: [{ '$u.name': 'Alice' }], }, }); - const og = new Omnigraph({ baseUrl: 'http://x', token: 't', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', token: 't', graphId: 'g', fetch }); const r = await og.queries.invoke('find inactive', { params: { min_days: 30 }, branch: 'main', }); expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/queries/find%20inactive'); + expect(calls[0]?.url).toBe('http://x/graphs/g/queries/find%20inactive'); const body = JSON.parse(calls[0]?.body ?? '{}'); // `params` is opaque: the snake_case key must reach the wire unchanged. expect(body.params).toEqual({ min_days: 30 }); @@ -55,4 +55,26 @@ describe('queries resource (stored queries)', () => { expect(read.rowCount).toBe(1); expect(read.rows[0]?.['$u.name']).toBe('Alice'); }); + + it('serializes expectMutation → expect_mutation on the wire', async () => { + const { fetch, calls } = stubFetch({ + body: { query_name: 'q', row_count: 0, columns: [], rows: [] }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + await og.queries.invoke('q', { expectMutation: false, branch: 'main' }); + const body = JSON.parse(calls[0]?.body ?? '{}'); + expect(body.expect_mutation).toBe(false); + expect(body.branch).toBe('main'); + }); + + it('surfaces a kind-mismatch 400 as BadRequestError', async () => { + const { fetch } = stubFetch({ + status: 400, + body: { error: 'stored query is a mutation, not a read', code: 'bad_request' }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + await expect( + og.queries.invoke('q', { expectMutation: false }), + ).rejects.toBeInstanceOf(BadRequestError); + }); }); diff --git a/packages/sdk/test/schema.test.ts b/packages/sdk/test/schema.test.ts index 56b4099..a3899f9 100644 --- a/packages/sdk/test/schema.test.ts +++ b/packages/sdk/test/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import Omnigraph from '../src'; +import Omnigraph, { ConflictError } from '../src'; import { stubFetch } from './helpers'; describe('schema resource', () => { @@ -7,10 +7,10 @@ describe('schema resource', () => { const { fetch, calls } = stubFetch({ body: { schema_source: 'node Person { name: String @key }' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.schema.get(); expect(calls[0]?.method).toBe('GET'); - expect(calls[0]?.url).toBe('http://x/schema'); + expect(calls[0]?.url).toBe('http://x/graphs/g/schema'); expect(r.schemaSource).toContain('node Person'); }); @@ -23,10 +23,10 @@ describe('schema resource', () => { supported: true, }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.schema.apply({ schemaSource: 'node Foo { id: String @key }' }); expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/schema/apply'); + expect(calls[0]?.url).toBe('http://x/graphs/g/schema/apply'); expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ schema_source: 'node Foo { id: String @key }', }); @@ -38,7 +38,7 @@ describe('schema resource', () => { const { fetch } = stubFetch({ body: { applied: false, manifest_version: 5, steps: [], supported: true }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const r = await og.schema.apply({ schemaSource: 'node Foo { id: String @key }' }); expect(r.applied).toBe(false); }); @@ -47,7 +47,7 @@ describe('schema resource', () => { const { fetch, calls } = stubFetch({ body: { applied: true, manifest_version: 6, steps: [], supported: true }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.schema.apply({ schemaSource: 'node Foo { id: String @key }', allowDataLoss: true, @@ -62,10 +62,32 @@ describe('schema resource', () => { const { fetch, calls } = stubFetch({ body: { applied: true, manifest_version: 6, steps: [], supported: true }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.schema.apply({ schemaSource: 'node Foo { id: String @key }' }); const body = JSON.parse(calls[0]?.body ?? '{}'); expect(body).toEqual({ schema_source: 'node Foo { id: String @key }' }); expect('allow_data_loss' in body).toBe(false); }); + + it('maps a bare 409 (schema apply disabled for cluster graph) to ConflictError', async () => { + // Server 0.7.0 refuses schema apply on a cluster-managed graph with a plain + // 409 — no merge_conflicts / manifest_conflict payload. The conflict fields + // must stay undefined, not throw. + const { fetch } = stubFetch({ + status: 409, + body: { + error: 'schema apply disabled for cluster-backed serving', + code: 'conflict', + }, + }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); + try { + await og.schema.apply({ schemaSource: 'node Foo { id: String @key }' }); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ConflictError); + expect((e as ConflictError).mergeConflicts).toBeUndefined(); + expect((e as ConflictError).manifestConflict).toBeUndefined(); + } + }); }); diff --git a/packages/sdk/test/stream.test.ts b/packages/sdk/test/stream.test.ts index 87c5c10..d42b2cd 100644 --- a/packages/sdk/test/stream.test.ts +++ b/packages/sdk/test/stream.test.ts @@ -14,7 +14,7 @@ describe('export streaming', () => { body: ndjson, headers: { 'content-type': 'application/x-ndjson' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const rows: unknown[] = []; for await (const r of og.export({ branch: 'main' })) { rows.push(r); @@ -38,7 +38,7 @@ describe('export streaming', () => { body: ndjson, headers: { 'content-type': 'application/x-ndjson' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const rows: unknown[] = []; for await (const r of og.export()) rows.push(r); expect(rows).toEqual([{ a: 1 }, { b: 2 }]); @@ -50,7 +50,7 @@ describe('export streaming', () => { body: ndjson, headers: { 'content-type': 'application/x-ndjson' }, }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); const rows: unknown[] = []; for await (const r of og.export()) rows.push(r); expect(rows).toEqual([{ a: 1 }, { b: 2 }]); diff --git a/packages/sdk/test/transport.test.ts b/packages/sdk/test/transport.test.ts index 4ef62ad..f69310a 100644 --- a/packages/sdk/test/transport.test.ts +++ b/packages/sdk/test/transport.test.ts @@ -1,39 +1,39 @@ import { describe, expect, it, vi } from 'vitest'; -import Omnigraph, { NetworkError } from '../src'; +import Omnigraph, { ConfigurationError, NetworkError } from '../src'; import { Transport } from '../src/transport'; import { stubFetch } from './helpers'; describe('transport URL handling', () => { it('strips trailing slashes from baseUrl', async () => { const { fetch, calls } = stubFetch({ body: { branches: [] } }); - const og = new Omnigraph({ baseUrl: 'http://x///', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x///', graphId: 'g', fetch }); await og.branches.list(); - expect(calls[0]?.url).toBe('http://x/branches'); + expect(calls[0]?.url).toBe('http://x/graphs/g/branches'); }); it('preserves a non-slashed baseUrl', async () => { const { fetch, calls } = stubFetch({ body: { branches: [] } }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.branches.list(); - expect(calls[0]?.url).toBe('http://x/branches'); + expect(calls[0]?.url).toBe('http://x/graphs/g/branches'); }); it('rejects paths missing a leading slash', async () => { const { fetch } = stubFetch({ body: {} }); - const t = new Transport({ baseUrl: 'http://x', fetch }); + const t = new Transport({ baseUrl: 'http://x', graphId: 'g', fetch }); await expect(t.request('GET', 'no-slash')).rejects.toThrow(/must start with '\/'/); }); it('omits null/undefined query params', async () => { const { fetch, calls } = stubFetch({ body: { commits: [] } }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.commits.list({ branch: undefined }); - expect(calls[0]?.url).toBe('http://x/commits'); + expect(calls[0]?.url).toBe('http://x/graphs/g/commits'); }); it('appends array query values as repeated keys', async () => { const { fetch, calls } = stubFetch({ body: {} }); - const t = new Transport({ baseUrl: 'http://x', fetch }); + const t = new Transport({ baseUrl: 'http://x', graphId: 'g', fetch }); await t.request('GET', '/p', { query: { tag: ['a', 'b'] } }); const u = new URL(calls[0]!.url); expect(u.searchParams.getAll('tag')).toEqual(['a', 'b']); @@ -139,23 +139,23 @@ describe('transport graphId prefixing', () => { expect(calls[1]?.url).toBe('http://x/graphs/alpha/schema/apply'); }); - it('prefixes /read, /query, /change, /mutate, /ingest, /snapshot, /export under /graphs/{graphId}', async () => { + it('prefixes /read, /query, /change, /mutate, /load, /ingest, /snapshot, /export under /graphs/{graphId}', async () => { + const ingestBody = { + actor_id: null, + base_branch: 'main', + branch: 'main', + branch_created: false, + mode: 'merge', + tables: [], + uri: 's3://x', + }; const { fetch, calls } = stubFetch([ { body: { rows: [], columns: [] } }, { body: { rows: [], columns: [] } }, { body: { affected_nodes: 0, affected_edges: 0 } }, { body: { affected_nodes: 0, affected_edges: 0 } }, - { - body: { - actor_id: null, - base_branch: 'main', - branch: 'main', - branch_created: false, - mode: 'merge', - tables: [], - uri: 's3://x', - }, - }, + { body: ingestBody }, + { body: ingestBody }, { body: { branch: 'main', tables: [] } }, { body: '', headers: { 'content-type': 'application/x-ndjson' } }, ]); @@ -164,6 +164,7 @@ describe('transport graphId prefixing', () => { await og.query({ query: 'query q() {}' }); await og.change({ query: 'query q() {}' }); await og.mutate({ query: 'query q() {}' }); + await og.load({ branch: 'main', mode: 'merge', data: '{}\n' }); await og.ingest({ branch: 'main', mode: 'merge', data: '{}\n' }); await og.snapshot(); for await (const _ of og.export({ branch: 'main' })) void _; @@ -171,9 +172,10 @@ describe('transport graphId prefixing', () => { expect(calls[1]?.url).toBe('http://x/graphs/alpha/query'); expect(calls[2]?.url).toBe('http://x/graphs/alpha/change'); expect(calls[3]?.url).toBe('http://x/graphs/alpha/mutate'); - expect(calls[4]?.url).toBe('http://x/graphs/alpha/ingest'); - expect(calls[5]?.url).toBe('http://x/graphs/alpha/snapshot'); - expect(calls[6]?.url).toBe('http://x/graphs/alpha/export'); + expect(calls[4]?.url).toBe('http://x/graphs/alpha/load'); + expect(calls[5]?.url).toBe('http://x/graphs/alpha/ingest'); + expect(calls[6]?.url).toBe('http://x/graphs/alpha/snapshot'); + expect(calls[7]?.url).toBe('http://x/graphs/alpha/export'); }); it('never prefixes /healthz', async () => { @@ -197,25 +199,39 @@ describe('transport graphId prefixing', () => { expect(calls[0]?.url).toBe('http://x/graphs/a%2Fb%20c/branches'); }); - it('omits the prefix entirely when graphId is undefined', async () => { + it('throws ConfigurationError for a graph-scoped op when graphId is undefined', async () => { const { fetch, calls } = stubFetch({ body: { branches: [] } }); const og = new Omnigraph({ baseUrl: 'http://x', fetch }); - await og.branches.list(); - expect(calls[0]?.url).toBe('http://x/branches'); + await expect(og.branches.list()).rejects.toBeInstanceOf(ConfigurationError); + await expect(og.branches.list()).rejects.toThrow(/graphId is required/); + // The guard fires before any network call. + expect(calls.length).toBe(0); + }); + + it('allows flat management ops (/healthz, /graphs) without a graphId', async () => { + const { fetch, calls } = stubFetch([ + { body: { status: 'ok', version: '0.7.0' } }, + { body: { graphs: [] } }, + ]); + const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + await og.health(); + await og.graphs.list(); + expect(calls[0]?.url).toBe('http://x/healthz'); + expect(calls[1]?.url).toBe('http://x/graphs'); }); }); describe('transport bearer auth', () => { it('attaches Authorization header when token is set', async () => { const { fetch, calls } = stubFetch({ body: { branches: [] } }); - const og = new Omnigraph({ baseUrl: 'http://x', token: 'tok-1', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', token: 'tok-1', graphId: 'g', fetch }); await og.branches.list(); expect(calls[0]?.headers['authorization']).toBe('Bearer tok-1'); }); it('omits Authorization header when token is unset', async () => { const { fetch, calls } = stubFetch({ body: { branches: [] } }); - const og = new Omnigraph({ baseUrl: 'http://x', fetch }); + const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); await og.branches.list(); expect(calls[0]?.headers['authorization']).toBeUndefined(); }); diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index 1b3c995..d289261 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -150,11 +150,23 @@ function normalizePath(p: string): string { return p.replace(/\{[^}]+\}/g, '{}').replace(/\$\{[^}]*\}/g, '{}'); } +// Mirror packages/sdk/src/transport.ts: a cluster-only server (0.7.0+) serves +// every graph-scoped operation under `/graphs/{graph_id}/…`, while the SDK call +// sites pass the flat path (e.g. `/branches`) and let the Transport add the +// prefix at runtime. Strip that prefix off spec paths before matching, exempting +// the same flat management paths the Transport never rewrites. +const FLAT_SPEC_PATHS = new Set(['/healthz', '/graphs']); +function flattenSpecPath(p: string): string { + if (FLAT_SPEC_PATHS.has(p)) return p; + const m = /^\/graphs\/\{[^}]+\}(\/.*)$/.exec(p); + return m ? m[1]! : p; +} + const errors: string[] = []; const expectedByKey = new Map(); for (const e of expected) { - expectedByKey.set(`${e.method} ${normalizePath(e.path)}`, e); + expectedByKey.set(`${e.method} ${normalizePath(flattenSpecPath(e.path))}`, e); } const seenInSdk = new Set(); @@ -181,7 +193,7 @@ for (const c of calls) { } for (const e of expected) { - const key = `${e.method} ${normalizePath(e.path)}`; + const key = `${e.method} ${normalizePath(flattenSpecPath(e.path))}`; if (!seenInSdk.has(key)) { errors.push(`No SDK binding for spec operation ${e.method} ${e.path}`); } diff --git a/spec/openapi.json b/spec/openapi.json index aced64d..fb76fae 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -7,17 +7,85 @@ "name": "MIT", "identifier": "MIT" }, - "version": "0.6.1" + "version": "0.7.0" }, "paths": { - "/branches": { + "/graphs": { + "get": { + "tags": [ + "management" + ], + "summary": "List every graph currently registered with this server (MR-668).", + "description": "Multi-graph mode only. In single mode, the route returns 405 — there's\nno registry to enumerate. Cedar-gated by the server-level policy via\nthe `graph_list` action against `Omnigraph::Server::\"root\"`.\n\nOrder: alphabetical by `graph_id` (server-sorted so clients see\ndeterministic output across requests).", + "operationId": "listGraphs", + "responses": { + "200": { + "description": "List of registered graphs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GraphListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, + "405": { + "description": "Method not allowed (single-graph mode)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + } + }, + "security": [ + { + "bearer_token": [] + } + ] + } + }, + "/graphs/{graph_id}/branches": { "get": { "tags": [ "branches" ], "summary": "List all branches.", "description": "Returns branch names sorted alphabetically. Read-only.", - "operationId": "listBranches", + "operationId": "cluster_listBranches", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "List of branches", @@ -62,7 +130,18 @@ ], "summary": "Create a new branch.", "description": "Forks `name` off of `from` (defaults to `main`). The new branch shares\ntable data with its parent until it is mutated. Returns 409 if `name`\nalready exists.", - "operationId": "createBranch", + "operationId": "cluster_createBranch", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -142,14 +221,25 @@ ] } }, - "/branches/merge": { + "/graphs/{graph_id}/branches/merge": { "post": { "tags": [ "branches" ], "summary": "Merge one branch into another.", "description": "Merges `source` into `target` (defaults to `main`). Outcome is one of\n`already_up_to_date`, `fast_forward`, or `merged`. Returns 409 with the\nlist of conflicts if the merge cannot be completed; the target is left\nunchanged in that case. **Destructive** to `target` on success.", - "operationId": "mergeBranches", + "operationId": "cluster_mergeBranches", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -229,15 +319,24 @@ ] } }, - "/branches/{branch}": { + "/graphs/{graph_id}/branches/{branch}": { "delete": { "tags": [ "branches" ], "summary": "Delete a branch.", "description": "**Irreversible.** Removes the branch pointer; commits remain reachable\nonly if referenced by another branch. Returns 404 if the branch does not\nexist.", - "operationId": "deleteBranch", + "operationId": "cluster_deleteBranch", "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "branch", "in": "path", @@ -307,14 +406,25 @@ ] } }, - "/change": { + "/graphs/{graph_id}/change": { "post": { "tags": [ "mutations" ], "summary": "**Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.", - "description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", - "operationId": "change", + "description": "Apply a GQ mutation to a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /mutate`, which has identical semantics and a name that pairs\ncleanly with `POST /query`. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", + "operationId": "cluster_change", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -327,7 +437,7 @@ }, "responses": { "200": { - "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -395,15 +505,24 @@ ] } }, - "/commits": { + "/graphs/{graph_id}/commits": { "get": { "tags": [ "commits" ], "summary": "List commits.", "description": "Filter by `branch` to get the commits on a single branch (most recent\nfirst); omit to list across all branches. Read-only.", - "operationId": "listCommits", + "operationId": "cluster_listCommits", "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "branch", "in": "query", @@ -455,15 +574,24 @@ ] } }, - "/commits/{commit_id}": { + "/graphs/{graph_id}/commits/{commit_id}": { "get": { "tags": [ "commits" ], "summary": "Get a single commit.", "description": "Returns the commit's manifest version, parent commit(s), and creation\nmetadata. Read-only.", - "operationId": "getCommit", + "operationId": "cluster_getCommit", "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "commit_id", "in": "path", @@ -523,14 +651,25 @@ ] } }, - "/export": { + "/graphs/{graph_id}/export": { "post": { "tags": [ "queries" ], "summary": "Stream the contents of a branch as NDJSON.", "description": "Emits one JSON object per line (`application/x-ndjson`). Filter with\n`type_names` (node/edge type names) and/or `table_keys`; both empty\nstreams the entire branch. Suitable for large exports — the response is\nstreamed, not buffered. Read-only.", - "operationId": "export", + "operationId": "cluster_export", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -586,21 +725,52 @@ ] } }, - "/graphs": { - "get": { + "/graphs/{graph_id}/ingest": { + "post": { "tags": [ - "management" + "mutations" ], - "summary": "List every graph currently registered with this server (MR-668).", - "description": "Multi-graph mode only. In single mode, the route returns 405 — there's\nno registry to enumerate. Cedar-gated by the server-level policy via\nthe `graph_list` action against `Omnigraph::Server::\"root\"`.\n\nOrder: alphabetical by `graph_id` (server-sorted so clients see\ndeterministic output across requests).", - "operationId": "listGraphs", + "summary": "**Deprecated** — use [`POST /load`](#tag/mutations/operation/load) instead.", + "description": "Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is\nkept indefinitely for back-compat. New integrations should target\n`POST /load`, which has identical semantics. Responses from this route\ninclude `Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.", + "operationId": "cluster_ingest", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IngestRequest" + } + } + }, + "required": true + }, "responses": { "200": { - "description": "List of registered graphs", + "description": "Load results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GraphListResponse" + "$ref": "#/components/schemas/IngestOutput" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" } } } @@ -625,8 +795,8 @@ } } }, - "405": { - "description": "Method not allowed (single-graph mode)", + "429": { + "description": "Per-actor admission cap exceeded; honor `Retry-After` header", "content": { "application/json": { "schema": { @@ -636,6 +806,7 @@ } } }, + "deprecated": true, "security": [ { "bearer_token": [] @@ -643,36 +814,25 @@ ] } }, - "/healthz": { - "get": { + "/graphs/{graph_id}/load": { + "post": { "tags": [ - "health" + "mutations" ], - "summary": "Liveness probe.", - "description": "Returns server status and version. Unauthenticated; safe to call from any\ncaller. Use this to confirm the server is reachable before invoking other\nendpoints.", - "operationId": "health", - "responses": { - "200": { - "description": "Server is healthy", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthOutput" - } - } + "summary": "Bulk-load NDJSON data into a branch (canonical load endpoint).", + "description": "`data` is NDJSON with one record per line. `mode` controls behavior on\nexisting rows: `merge` upserts by id (default), `append` blindly inserts,\n`overwrite` replaces table contents. Branch creation is opt-in by\npresence of `from`: with `from` set, a missing `branch` is created from\nit; without `from`, `branch` must already exist — a missing branch is a\n404, never an implicit fork. **Destructive** when `mode` is `overwrite`\nor when the load produces conflicting writes.\n\nThe legacy `POST /ingest` route has identical semantics and is kept as a\ndeprecated alias.", + "operationId": "cluster_load", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" } } - } - } - }, - "/ingest": { - "post": { - "tags": [ - "mutations" ], - "summary": "Bulk-ingest NDJSON data into a branch.", - "description": "`data` is NDJSON with one record per line. `mode` controls behavior on\nexisting rows: `merge` upserts by id (default), `append` blindly inserts,\n`overwrite` replaces table contents. If `branch` does not exist it is\ncreated from `from` (defaults to `main`). **Destructive** when `mode` is\n`overwrite` or when ingest produces conflicting writes.", - "operationId": "ingest", "requestBody": { "content": { "application/json": { @@ -685,7 +845,7 @@ }, "responses": { "200": { - "description": "Ingest results", + "description": "Load results", "content": { "application/json": { "schema": { @@ -742,14 +902,25 @@ ] } }, - "/mutate": { + "/graphs/{graph_id}/mutate": { "post": { "tags": [ "mutations" ], "summary": "Apply a GQ mutation to a branch (canonical mutation endpoint).", "description": "Writes to the named `branch` (defaults to `main`). Mutations are atomic\nper call and produce a new commit. Returns counts of nodes and edges\naffected. **Destructive**: on success the branch is updated; rejected\nmutations may still acquire locks briefly. Returns 409 on merge conflict.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.", - "operationId": "mutate", + "operationId": "cluster_mutate", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -829,14 +1000,25 @@ ] } }, - "/queries": { + "/graphs/{graph_id}/queries": { "get": { "tags": [ "queries" ], "summary": "List the graph's exposed stored queries as a typed tool catalog.", "description": "Returns the `mcp.expose == true` subset of the `queries:` registry, each\nwith its MCP tool name, read/mutate flag, description/instruction, and\ntyped parameters — enough for a client to register them as tools without\nfetching `.gq` source. Read-gated; the catalog is graph-wide (branch\nindependent — `read` is authorized against `main`). **Not** Cedar-filtered\nper query yet, so it can list a query whose `invoke_query` the caller\nlacks (a known gap until per-query authorization lands).", - "operationId": "list_queries", + "operationId": "cluster_list_queries", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Stored-query catalog (the mcp.expose subset, with typed params)", @@ -876,15 +1058,24 @@ ] } }, - "/queries/{name}": { + "/graphs/{graph_id}/queries/{name}": { "post": { "tags": [ "queries" ], "summary": "Invoke a curated, server-side stored query by name.", "description": "The query source comes from the graph's `queries:` registry, not the\nrequest body — callers send only runtime inputs (`params`, `branch`,\n`snapshot`). Gated by the `invoke_query` Cedar action at the boundary;\na stored *mutation* additionally passes the engine's `change` gate\n(double-gated). An actor **without** `invoke_query` cannot tell a denied\nquery from a missing one — both return the same 404, so the catalog\ncan't be probed without the grant. Once `invoke_query` is held, the\ninner `read`/`change` gate may surface a 403 for an existing query the\nactor can't run (the intended double-gate signal).", - "operationId": "invoke_query", + "operationId": "cluster_invoke_query", "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "name", "in": "path", @@ -1000,14 +1191,25 @@ ] } }, - "/query": { + "/graphs/{graph_id}/query": { "post": { "tags": [ "queries" ], "summary": "Execute an inline read query (friendlier-named alternative to `POST /read`).", "description": "Designed for ad-hoc exploration and AI-agent tool-use: short field\nnames (`query`, `name`) match the CLI `-e` flag and the GQ `query`\nkeyword. Mutations (`insert`/`update`/`delete`) are rejected with 400\n-- use `POST /mutate` (or its deprecated alias `POST /change`) for\nwrite queries. Otherwise behaves identically to `POST /read`: same\ntarget semantics (branch xor snapshot), same Cedar action (Read),\nsame response shape.", - "operationId": "query", + "operationId": "cluster_query", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1067,14 +1269,25 @@ ] } }, - "/read": { + "/graphs/{graph_id}/read": { "post": { "tags": [ "queries" ], "summary": "**Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.", - "description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", - "operationId": "read", + "description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", + "operationId": "cluster_read", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1087,7 +1300,7 @@ }, "responses": { "200": { - "description": "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", + "description": "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)", "content": { "application/json": { "schema": { @@ -1135,14 +1348,25 @@ ] } }, - "/schema": { + "/graphs/{graph_id}/schema": { "get": { "tags": [ "schema" ], "summary": "Read the current schema source.", "description": "Returns the project's schema as a single string in `.pg` source form.\nUseful for clients that want to introspect available types and tables\nbefore constructing GQ queries. Read-only.", - "operationId": "getSchema", + "operationId": "cluster_getSchema", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Current schema source", @@ -1182,14 +1406,25 @@ ] } }, - "/schema/apply": { + "/graphs/{graph_id}/schema/apply": { "post": { "tags": [ "mutations" ], "summary": "Apply a schema migration.", - "description": "Diffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop column, etc.). **Destructive**:\nsome steps drop data. Returns the list of steps applied; if `applied` is\nfalse the diff was unsupported and no changes were made.", - "operationId": "applySchema", + "description": "Cluster-backed servers reject this route with `409 Conflict`; operators\nmust apply schema changes through `omnigraph cluster apply` and restart.\n\nDiffs `schema_source` against the current schema and applies the resulting\nmigration steps (add/drop type, add/drop column, etc.). **Destructive**:\nsome steps drop data. Returns the list of steps applied; if `applied` is\nfalse the diff was unsupported and no changes were made.", + "operationId": "cluster_applySchema", + "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + } + ], "requestBody": { "content": { "application/json": { @@ -1241,6 +1476,16 @@ } } }, + "409": { + "description": "Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "429": { "description": "Per-actor admission cap exceeded; honor `Retry-After` header", "content": { @@ -1259,15 +1504,24 @@ ] } }, - "/snapshot": { + "/graphs/{graph_id}/snapshot": { "get": { "tags": [ "snapshots" ], "summary": "Read the current snapshot of a branch.", "description": "Returns the manifest version plus per-table metadata (path, version, row\ncount) for every table on the branch. Defaults to `main` when `branch` is\nomitted. Read-only.", - "operationId": "getSnapshot", + "operationId": "cluster_getSnapshot", "parameters": [ + { + "name": "graph_id", + "in": "path", + "description": "Graph id to route the request to.", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "branch", "in": "query", @@ -1318,6 +1572,28 @@ } ] } + }, + "/healthz": { + "get": { + "tags": [ + "health" + ], + "summary": "Liveness probe.", + "description": "Returns server status and version. Unauthenticated; safe to call from any\ncaller. Use this to confirm the server is reachable before invoking other\nendpoints.", + "operationId": "health", + "responses": { + "200": { + "description": "Server is healthy", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthOutput" + } + } + } + } + } + } } }, "components": { @@ -1710,7 +1986,6 @@ "required": [ "uri", "branch", - "base_branch", "branch_created", "mode", "tables" @@ -1723,7 +1998,11 @@ ] }, "base_branch": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "Base branch a fork was requested from (the request's `from`), echoed\neven when the branch already existed. `null` when `from` was absent." }, "branch": { "type": "string" @@ -1756,7 +2035,7 @@ "string", "null" ], - "description": "Target branch. Created from `from` if it does not yet exist. Defaults to `main`." + "description": "Target branch. Defaults to `main`. Without `from`, the branch must\nalready exist — a missing branch is a 404, never an implicit fork." }, "data": { "type": "string", @@ -1768,7 +2047,7 @@ "string", "null" ], - "description": "Parent branch used to create `branch` if it does not exist. Defaults to `main`." + "description": "Parent branch used to create `branch` if it does not exist. Branch\ncreation is opt-in by presence of this field; omit it to require an\nexisting branch." }, "mode": { "oneOf": [ @@ -1810,6 +2089,13 @@ ], "description": "Branch to run against. Defaults to `main`; for a stored mutation the\nwrite targets this branch." }, + "expect_mutation": { + "type": [ + "boolean", + "null" + ], + "description": "The kind the caller expects (RFC-011 Decision 3): `Some(false)` for\n`omnigraph query `, `Some(true)` for `omnigraph mutate `.\nWhen set and it disagrees with the stored query's actual kind, the\nserver rejects the call (400) so the verb asserts the kind. `None`\n(the default) skips the check — preserving older clients and aliases." + }, "params": { "description": "JSON object whose keys match the stored query's declared parameters." }, From 97bfb58cf2f53c4b5f705dd0f49e8d5b1f2ba2fb Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 16 Jun 2026 17:06:56 +0200 Subject: [PATCH 2/3] refactor!: remove deprecated read/change/ingest aliases (clean break) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This major release drops the deprecated SDK methods and MCP tools in favor of a single canonical surface: query / mutate / load. Removed: - SDK: og.read(), og.change(), og.ingest() and the ChangeInput / LegacyChangeInput / ReadInput types (+ the change-input normalization shim). - MCP: the `read`, `change`, `ingest` tools (and their legacy-field handling). The server still serves /read, /change, /ingest as indefinite shims, so a pre-0.7 SDK keeps working — this SDK just no longer calls them. check-coverage now carries an explicit INTENTIONALLY_UNBOUND allowlist for those three spec ops, logged on every run (never silent) and fail-fast if the allowlist goes stale, so coverage stays honest for everything else. Tests converted (read→query, change→mutate, ingest→load) or dropped where they exercised removed alias behavior; READMES + MCP instructions updated to the canonical verbs. Verified: full gate green; 86 unit + 20 live e2e tests pass against the edge (latest upstream) server. --- packages/mcp/README.md | 9 +- packages/mcp/cookbook-descriptions.json | 6 +- packages/mcp/src/server.ts | 130 ++---------------------- packages/mcp/test/server.test.ts | 80 +++------------ packages/sdk/README.md | 14 ++- packages/sdk/src/client.ts | 100 ++---------------- packages/sdk/src/index.ts | 5 +- packages/sdk/src/types.ts | 17 ---- packages/sdk/test/client.test.ts | 77 -------------- packages/sdk/test/e2e.test.ts | 59 ++++------- packages/sdk/test/enums.test.ts | 2 +- packages/sdk/test/errors.test.ts | 2 +- packages/sdk/test/opaque.test.ts | 16 +-- packages/sdk/test/stream.test.ts | 2 +- packages/sdk/test/transport.test.ts | 25 ++--- scripts/check-coverage.ts | 38 ++++++- 16 files changed, 119 insertions(+), 463 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d24c629..aaad566 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -56,8 +56,7 @@ Read-only (`readOnlyHint: true`): |---|---| | `health` | Server liveness + version | | `snapshot` | Snapshot of a branch (table list + row counts) | -| `query` | Run a `.gq` read query (canonical; successor to `read`) | -| `read` | Legacy alias for `query`. Field names are still `querySource` / `queryName`; prefer `query`. | +| `query` | Run a `.gq` read query | | `schema_get` | Active `.pg` schema source | | `branches_list` | List user-visible branches | | `commits_list` | List commits on a branch | @@ -68,10 +67,8 @@ Mutating (`destructiveHint: true` where appropriate — hosts should surface con | Tool | Purpose | |---|---| -| `mutate` | Run a `.gq` mutation (canonical; successor to `change`) | -| `change` | Legacy alias for `mutate`. Accepts either legacy `querySource` / `queryName` or canonical `query` / `name`; mixed field families are rejected. Prefer `mutate`. | -| `load` | Bulk-load NDJSON (canonical; `mode: 'merge'` for idempotency). Without `from`, a missing branch is a 404. | -| `ingest` | Deprecated alias of `load` (kept as a shim). Identical behavior; prefer `load`. | +| `mutate` | Run a `.gq` mutation | +| `load` | Bulk-load NDJSON (`mode: 'merge'` for idempotency). Without `from`, a missing branch is a 404. | | `branches_create` | Create a new branch | | `branches_delete` | Delete a branch | | `branches_merge` | Merge `source` into `target` | diff --git a/packages/mcp/cookbook-descriptions.json b/packages/mcp/cookbook-descriptions.json index e47d64b..7360584 100644 --- a/packages/mcp/cookbook-descriptions.json +++ b/packages/mcp/cookbook-descriptions.json @@ -2,11 +2,11 @@ "exposed": { "queries": { "title": "GQ query best practices", - "description": "Read before authoring queries (via `read` or `change`). Covers .gq grammar, query name dispatch, parameter declaration, the PascalCase→lowerCamelCase edge casing trap, search functions requiring trailing `limit`, and the T12 non-nullable lint rule." + "description": "Read before authoring queries (via `query` or `mutate`). Covers .gq grammar, query name dispatch, parameter declaration, the PascalCase→lowerCamelCase edge casing trap, search functions requiring trailing `limit`, and the T12 non-nullable lint rule." }, "data": { - "title": "Data ingest patterns", - "description": "Read before calling `ingest`. Covers mode selection (merge/append/overwrite), the branch→ingest→verify→merge loop for large/risky writes, embedding-staleness gotcha (mode:merge does not recompute embeddings), and change-vs-ingest decision matrix." + "title": "Data load patterns", + "description": "Read before calling `load`. Covers mode selection (merge/append/overwrite), the branch→load→verify→merge loop for large/risky writes, embedding-staleness gotcha (mode:merge does not recompute embeddings), and mutate-vs-load decision matrix." }, "schema": { "title": "Schema authoring and evolution", diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 4d91bdd..5f56246 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -2,9 +2,9 @@ // in MCP tools (LLM-callable) and resources (LLM-readable). Designed for // MCP SDK v1.x; the v2 split-package layout is a follow-up. // -// Tools mutate or query the live database. Read-only tools (read, snapshot, +// Tools mutate or query the live database. Read-only tools (query, snapshot, // branches.list, commits.list, schema.get, health) carry no destructive -// side effects. Mutating tools (change, ingest, schema.apply, branch +// side effects. Mutating tools (mutate, load, schema.apply, branch // create/delete/merge) are annotated with `destructiveHint: true` so MCP // hosts can surface a confirmation UI. // @@ -23,29 +23,29 @@ import { MCP_PACKAGE_VERSION } from './version.gen'; const INSTRUCTIONS = `Omnigraph is a versioned property graph. Reads are typed GQ queries; writes are server-orchestrated and branchable. -ALWAYS read \`omnigraph://schema\` (or call \`schema_get\`) FIRST, before any query, mutation, or ingest. Schema declares node/edge types, @key fields, non-nullable properties, edge directions, and casing. Writing without seeing the schema produces queries that lint-fail or silently corrupt data. +ALWAYS read \`omnigraph://schema\` (or call \`schema_get\`) FIRST, before any query, mutation, or load. Schema declares node/edge types, @key fields, non-nullable properties, edge directions, and casing. Writing without seeing the schema produces queries that lint-fail or silently corrupt data. After schema, consult the matching best-practices resource for the task at hand: - - omnigraph://best-practices/queries — before .gq queries (read/change) - - omnigraph://best-practices/data — before ingest (mode selection, branch loop) + - omnigraph://best-practices/queries — before .gq queries (query/mutate) + - omnigraph://best-practices/data — before load (mode selection, branch loop) - omnigraph://best-practices/schema — before schema_apply - omnigraph://best-practices/remote-ops — after any 504 or unexpected error - omnigraph://best-practices/search — before nearest/bm25/rrf queries Workflow norms (violating these breaks things or silently corrupts data): -1. .gq edges use lowerCamelCase even though the schema declares them PascalCase. No top-level \`mutation { }\` wrapper — every block is \`query name($p: T) { insert|update|delete ... }\`. Dispatch writes via \`change\`, not \`read\`. +1. .gq edges use lowerCamelCase even though the schema declares them PascalCase. No top-level \`mutation { }\` wrapper — every block is \`query name($p: T) { insert|update|delete ... }\`. Dispatch writes via \`mutate\`, not \`query\`. 2. Parameterize. Pass values via \`params\`, never interpolate into the query body. Declare typed params: \`query foo($slug: String) { ... }\`. 3. \`nearest\`, \`bm25\`, and \`rrf\` require a trailing \`limit N\` — they are ordering operators, not filters. -4. \`ingest mode: "merge"\` upserts by @key (idempotent — use this for at-least-once pipelines). \`"overwrite"\` truncates the branch. \`"append"\` fails on key collision. +4. \`load mode: "merge"\` upserts by @key (idempotent — use this for at-least-once pipelines). \`"overwrite"\` truncates the branch. \`"append"\` fails on key collision. 5. Verify every write. \`commits_list\` head BEFORE and AFTER. If identical, the write did not land. 504s do not mean failure — the server may have committed after the proxy dropped the response. 6. Append-only types (Signal, Claim, Decision, Event, Interaction, Policy, Outcome, MarketingElement) duplicate on blind retry. Pointer types (Org, Person, Opportunity, Channel, Actor, ActionItem, Artifact, Meeting, Technology, Campaign, UseCase) dedupe via @key. -7. Risky/large writes: \`branches_create\` from main → \`ingest\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`. \`schema_apply\` skips branches: it is main-only and rejects open feature branches. +7. Risky/large writes: \`branches_create\` from main → \`load\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`. \`schema_apply\` skips branches: it is main-only and rejects open feature branches. 8. \`schema_apply\` is destructive and has no undo. Use \`schema_get\` + a local diff first. Non-nullable property adds require add-optional → backfill → tighten in two applies. -Date format: ISO strings on \`change\` params; integer days-since-epoch in ingest JSONL \`Date\` fields. \`DateTime\` is ISO on both. +Date format: ISO strings on \`mutate\` params; integer days-since-epoch in load JSONL \`Date\` fields. \`DateTime\` is ISO on both. -If you see \`sync_branch()\` in an error message, it is server-internal text, NOT a tool. Retry once; on persistent failure, fall back to \`ingest\` on a branch. +If you see \`sync_branch()\` in an error message, it is server-internal text, NOT a tool. Retry once; on persistent failure, fall back to \`load\` on a branch. Depth: https://github.com/ModernRelay/omnigraph/tree/main/skills/omnigraph`; @@ -66,25 +66,6 @@ export interface CreateServerOptions { } const LoadModeEnum = z.enum(['overwrite', 'append', 'merge']); -const McpCanonicalChangeSchema = z - .object({ - query: z.string().min(1), - name: z.string().optional(), - params: z.record(z.unknown()).optional(), - branch: z.string().optional(), - }) - .strict(); -const McpLegacyChangeSchema = z - .object({ - querySource: z.string().min(1), - queryName: z.string().optional(), - params: z.record(z.unknown()).optional(), - branch: z.string().optional(), - }) - .strict(); -const McpChangeSchema = z.union([McpCanonicalChangeSchema, McpLegacyChangeSchema]); - -type McpChangeInput = z.infer; function jsonText(value: unknown) { return [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }]; @@ -94,23 +75,6 @@ function plainText(text: string) { return [{ type: 'text' as const, text }]; } -function normalizeMcpChangeInput(input: McpChangeInput) { - if ('querySource' in input) { - return { - query: input.querySource, - name: input.queryName, - params: input.params, - branch: input.branch, - }; - } - return { - query: input.query, - name: input.name, - params: input.params, - branch: input.branch, - }; -} - export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { const og = new Omnigraph({ baseUrl: opts.baseUrl, @@ -198,36 +162,6 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { }, ); - server.registerTool( - 'read', - { - title: 'Run GQ read query (legacy)', - description: - 'Legacy alias for `query` — prefer `query` for new callers. Runs the same ' + - '.gq read against `POST /read` instead of `POST /query`; server responds ' + - 'with `Deprecation: true` and a `Link: rel="successor-version"` header. ' + - 'Field names here are the legacy `querySource` / `queryName`.', - inputSchema: { - querySource: z.string().min(1), - queryName: z.string().optional(), - params: z.record(z.unknown()).optional(), - branch: z.string().optional(), - snapshot: z.string().optional(), - }, - annotations: { readOnlyHint: true, openWorldHint: false }, - }, - async ({ querySource, queryName, params, branch, snapshot }) => { - const r = await og.read({ - querySource, - queryName, - params, - branch: snapshot ? branch : (branch ?? defaultBranch), - snapshot, - }); - return { content: jsonText(r) }; - }, - ); - server.registerTool( 'schema_get', { @@ -333,32 +267,12 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { }, ); - server.registerTool( - 'change', - { - title: 'Run GQ mutation (legacy)', - description: - 'Legacy alias for `mutate` — prefer `mutate` for new callers. Same behavior, ' + - 'sent to `POST /change` instead of `POST /mutate`; server responds with ' + - '`Deprecation: true` and a `Link: rel="successor-version"` header. Accepts ' + - 'both legacy `querySource` / `queryName` and canonical `query` / `name`; ' + - 'mixed field families are rejected.', - inputSchema: McpChangeSchema, - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, - }, - async (input) => { - const normalized = normalizeMcpChangeInput(input); - const r = await og.change({ ...normalized, branch: normalized.branch ?? defaultBranch }); - return { content: jsonText(r) }; - }, - ); - server.registerTool( 'load', { title: 'Bulk-load NDJSON', description: - 'Bulk-load NDJSON data into a branch (canonical, server 0.7.0+). `mode: "merge"` upserts by @key (idempotent). ' + + 'Bulk-load NDJSON data into a branch. `mode: "merge"` upserts by @key (idempotent). ' + '`mode: "append"` is strict insert (errors on duplicate). `mode: "overwrite"` replaces all data. ' + 'Without `from`, the target branch must already exist (a missing branch is a 404); pass `from` to fork-if-missing.', inputSchema: { @@ -375,28 +289,6 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { }, ); - server.registerTool( - 'ingest', - { - title: 'Bulk-ingest NDJSON (deprecated alias of load)', - description: - 'Deprecated alias of `load` (kept indefinitely as a shim). Identical behavior; prefer `load`. ' + - '`mode: "merge"` upserts by @key (idempotent). ' + - '`mode: "append"` is strict insert (errors on duplicate). `mode: "overwrite"` replaces all data.', - inputSchema: { - branch: z.string().min(1), - from: z.string().optional(), - mode: LoadModeEnum, - data: z.string().min(1), - }, - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, - }, - async ({ branch, from, mode, data }) => { - const r = await og.ingest({ branch, from, mode, data }); - return { content: jsonText(r) }; - }, - ); - server.registerTool( 'branches_create', { diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index e3fae0e..d9ae74c 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -104,16 +104,13 @@ describe('omnigraph-mcp server', () => { 'branches_delete', 'branches_list', 'branches_merge', - 'change', 'commits_get', 'commits_list', 'graphs_list', 'health', - 'ingest', 'load', 'mutate', 'query', - 'read', 'schema_apply', 'schema_get', 'snapshot', @@ -125,13 +122,10 @@ describe('omnigraph-mcp server', () => { const { client } = await setup(); const { tools } = await client.listTools(); const byName = new Map(tools.map((t) => [t.name, t])); - expect(byName.get('change')?.annotations?.destructiveHint).toBe(true); expect(byName.get('load')?.annotations?.destructiveHint).toBe(true); - expect(byName.get('ingest')?.annotations?.destructiveHint).toBe(true); expect(byName.get('branches_delete')?.annotations?.destructiveHint).toBe(true); expect(byName.get('branches_merge')?.annotations?.destructiveHint).toBe(true); expect(byName.get('schema_apply')?.annotations?.destructiveHint).toBe(true); - expect(byName.get('read')?.annotations?.readOnlyHint).toBe(true); expect(byName.get('snapshot')?.annotations?.readOnlyHint).toBe(true); expect(byName.get('schema_get')?.annotations?.readOnlyHint).toBe(true); }); @@ -146,13 +140,13 @@ describe('omnigraph-mcp server', () => { expect(parsed.sdkServerVersion).toBe('0.7.0'); }); - it('calls the read tool and preserves opaque param keys', async () => { + it('calls the query tool and preserves opaque param keys', async () => { const { client } = await setup(); const r = await client.callTool({ - name: 'read', + name: 'query', arguments: { - querySource: 'query q($name: String) { match { $p: Person { name: $name } } return { $p.name } }', - queryName: 'q', + query: 'query q($name: String) { match { $p: Person { name: $name } } return { $p.name } }', + name: 'q', params: { name: 'Alice', $internal: 1 }, branch: 'main', }, @@ -164,13 +158,13 @@ describe('omnigraph-mcp server', () => { expect(parsed.rows[0]['$p.name']).toBe('Alice'); }); - it('change tool accepts legacy querySource/queryName and emits canonical wire fields', async () => { + it('mutate tool accepts canonical query/name fields', async () => { let observedBody: Record | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; const path = flatPath(url); const method = init?.method ?? 'GET'; - if (method === 'POST' && path === '/change') { + if (method === 'POST' && path === '/mutate') { observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); return new Response( JSON.stringify({ actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }), @@ -186,43 +180,7 @@ describe('omnigraph-mcp server', () => { await Promise.all([server.connect(serverT), client.connect(clientT)]); await client.callTool({ - name: 'change', - arguments: { - querySource: 'query q() { insert X {} }', - queryName: 'q', - branch: 'main', - }, - }); - expect(observedBody).toEqual({ - query: 'query q() { insert X {} }', - name: 'q', - branch: 'main', - }); - }); - - it('change tool accepts canonical query/name fields', async () => { - let observedBody: Record | undefined; - const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = flatPath(url); - const method = init?.method ?? 'GET'; - if (method === 'POST' && path === '/change') { - observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); - return new Response( - JSON.stringify({ actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); - }) as unknown as typeof globalThis.fetch; - - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: recordingFetch }); - const client = new Client({ name: 'test', version: '0.0.0' }); - const [clientT, serverT] = InMemoryTransport.createLinkedPair(); - await Promise.all([server.connect(serverT), client.connect(clientT)]); - - await client.callTool({ - name: 'change', + name: 'mutate', arguments: { query: 'query q() { insert X {} }', name: 'q', @@ -236,18 +194,6 @@ describe('omnigraph-mcp server', () => { }); }); - it('change tool rejects mixed canonical and legacy fields', async () => { - const { client } = await setup(); - const r = await client.callTool({ - name: 'change', - arguments: { - query: 'query q() { insert X {} }', - querySource: 'query q() { insert X {} }', - }, - }); - expect(r.isError).toBe(true); - }); - it('serves the schema resource with .pg source as text/plain', async () => { const { client } = await setup(); const r = await client.readResource({ uri: 'omnigraph://schema' }); @@ -336,13 +282,13 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as expect(observedFrom).toBe('review-2026'); }); - it('read tool omits branch when snapshot is set (mutually exclusive)', async () => { + it('query tool omits branch when snapshot is set (mutually exclusive)', async () => { let observedBody: Record | undefined; const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; const path = flatPath(url); const method = init?.method ?? 'GET'; - if (method === 'POST' && path === '/read') { + if (method === 'POST' && path === '/query') { observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); return new Response( JSON.stringify({ @@ -368,8 +314,8 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as await Promise.all([server.connect(serverT), client.connect(clientT)]); await client.callTool({ - name: 'read', - arguments: { querySource: 'query q { match { $p: Person } return { $p.name } }', snapshot: 'snap-1' }, + name: 'query', + arguments: { query: 'query q { match { $p: Person } return { $p.name } }', snapshot: 'snap-1' }, }); expect(observedBody?.snapshot).toBe('snap-1'); expect(observedBody?.branch).toBeUndefined(); @@ -411,8 +357,8 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as it('rejects calls with missing required input', async () => { const { client } = await setup(); - // querySource is required on `read`. - const r = await client.callTool({ name: 'read', arguments: {} }); + // query is required on `query`. + const r = await client.callTool({ name: 'query', arguments: {} }); expect(r.isError).toBe(true); }); }); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 53b8be1..fcce67f 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -38,7 +38,7 @@ That's the whole pattern: instantiate once (with a `graphId`), call methods, get > **`graphId` is required (server 0.7.0).** `omnigraph-server` is cluster-only: every graph-scoped operation is served under `/graphs/{graphId}/…`. A graph-scoped call without a `graphId` throws `ConfigurationError` before hitting the network. Only `og.health()` and `og.graphs.list()` work without one — use the latter to discover ids, then [`og.graph(id)`](#multi-graph-clusters). This SDK major.minor targets a 0.7.x server; for a 0.6.x (flat-route) server, stay on `@modernrelay/omnigraph@0.6.x`. -> **Migrating from `og.read` / `og.change` (server 0.6.0)** — `POST /query` and `POST /mutate` are the canonical successors. New mutation calls should use `og.mutate({ query, name })`. Deprecated `og.change()` remains source-compatible with both old `{ querySource, queryName }` callers and canonical `{ query, name }` callers; the SDK normalizes either shape to the server 0.6 wire body. `og.read()` keeps the legacy `{ querySource, queryName }` shape. +> **Removed in this release: `og.read`, `og.change`, `og.ingest`.** This major release drops the deprecated aliases for a single canonical surface — use **`og.query()`** (read), **`og.mutate()`** (write), and **`og.load()`** (bulk-load). Field names are `query` / `name` (not `querySource` / `queryName`). The server still serves the old `/read`, `/change`, `/ingest` routes as shims, so a 0.6.x-era SDK keeps working — but this SDK no longer calls them. ## What you can do @@ -91,7 +91,7 @@ await og.load({ }); ``` -`og.load()` is the canonical bulk-load endpoint (server 0.7.0). `og.ingest()` is a deprecated alias kept for back-compat — identical request/response shape; the server emits `Deprecation`/`Link` headers. **Loading into a branch that doesn't exist requires `from`** (the base to fork from); without it the server returns `NotFoundError` (404) rather than implicitly forking from `main`. +`og.load()` is the canonical (and only) bulk-load method. **Loading into a branch that doesn't exist requires `from`** (the base to fork from); without it the server returns `NotFoundError` (404) rather than implicitly forking from `main`. ### Stream a branch as NDJSON @@ -192,20 +192,18 @@ Omnigraph is a database; idempotency belongs in the schema (`@key`, `@unique`), | Operation | Retry semantics | |---|---| -| `og.health()`, `og.snapshot()`, `og.query()`, `og.read()`, `og.export()`, `og.branches.list()`, `og.commits.list()`, `og.commits.retrieve()`, `og.schema.get()` | Read-only — always safe. | +| `og.health()`, `og.snapshot()`, `og.query()`, `og.export()`, `og.branches.list()`, `og.commits.list()`, `og.commits.retrieve()`, `og.schema.get()`, `og.graphs.list()` | Read-only — always safe. | | `og.branches.create({ name })` | Throws `ConflictError` on retry (branch exists). Catch and treat as success. | | `og.branches.merge({ source, target })` | Idempotent — re-merge yields `outcome: 'already_up_to_date'`. | | `og.branches.delete(name)` | Idempotent — delete-of-deleted is a no-op. | | `og.schema.apply({ schemaSource })` | Idempotent — unchanged schema returns `applied: false`. | -| `og.load({ data, mode: 'merge' })` (or the deprecated `og.ingest`) | **Idempotent** — use this mode for at-least-once pipelines. Requires `@key` constraints. | +| `og.load({ data, mode: 'merge' })` | **Idempotent** — use this mode for at-least-once pipelines. Requires `@key` constraints. | | `og.load({ data, mode: 'overwrite' })` | Idempotent — same input → same final state. | | `og.load({ data, mode: 'append' })` | **Not idempotent** — blind insert. Avoid for retry-prone callers. | -| `og.mutate({ query })`, `og.change({ query })`, `og.change({ querySource })` | Depends on the query. `update X set ... where ...` is idempotent; `insert X { ... }` is idempotent only with `@unique` / `@key`. | +| `og.mutate({ query })` | Depends on the query. `update X set ... where ...` is idempotent; `insert X { ... }` is idempotent only with `@unique` / `@key`. | If a mutation isn't naturally idempotent, fix the schema (add `@unique` or `@key`) — not the SDK. -`og.read()` and `og.change()` are deprecated aliases of `og.query()` and `og.mutate()` (server 0.6.0). `og.change()` accepts both the old SDK fields (`querySource` / `queryName`) and the canonical mutation fields (`query` / `name`), then sends the canonical wire body. `og.read()` still uses `querySource` / `queryName`. - ## Cluster graphs `omnigraph-server` 0.7.0 is **cluster-only**: it hosts one or more graphs side-by-side under `/graphs/{graphId}/…`, declared in a `cluster.yaml`. Pick the graph with `graphId` (required for every graph-scoped call): @@ -218,7 +216,7 @@ const og = new Omnigraph({ }); await og.snapshot(); // → GET /graphs/alpha/snapshot -await og.read({ /* … */ }); // → POST /graphs/alpha/read +await og.query({ /* … */ }); // → POST /graphs/alpha/query ``` Use `og.graph(id)` to fan out across graphs from one parent client. It returns a new client that shares `baseUrl`, `token`, and `fetch`; the parent is untouched: diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 450e8ae..e95253e 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -9,7 +9,6 @@ import { QueriesResource } from './resources/queries'; import { SchemaResource } from './resources/schema'; import type { Change, - ChangeInput, ExportInput, Health, Ingest, @@ -17,7 +16,6 @@ import type { MutationInput, QueryInput, Read, - ReadInput, Snapshot, } from './types'; @@ -28,34 +26,10 @@ const OPAQUE_PARAMS = new Set(['params']); const OPAQUE_READ_RESPONSE = new Set(['rows', 'columns']); // Export NDJSON rows are `{ type, data }` (or `{ edge, from, to, data }`). // `data` contains the user-schema-driven properties whose keys must round-trip -// through ingest unchanged — keep verbatim. The envelope keys (`type`, `edge`, +// through load unchanged — keep verbatim. The envelope keys (`type`, `edge`, // `from`, `to`) are SDK/wire-defined and already match in both cases. const OPAQUE_EXPORT_ROW = new Set(['data']); -function normalizeChangeInput(input: ChangeInput): MutationInput { - const record = input as Record; - const hasCanonical = record.query !== undefined || record.name !== undefined; - const hasLegacy = record.querySource !== undefined || record.queryName !== undefined; - if (hasCanonical && hasLegacy) { - throw new TypeError('og.change() accepts either query/name or querySource/queryName, not both'); - } - if (hasLegacy) { - if (typeof record.querySource !== 'string' || record.querySource.length === 0) { - throw new TypeError('og.change() requires querySource when using legacy querySource/queryName fields'); - } - return { - query: record.querySource, - name: record.queryName as string | null | undefined, - params: record.params, - branch: record.branch as string | null | undefined, - }; - } - if (typeof record.query !== 'string' || record.query.length === 0) { - throw new TypeError('og.change() requires query when using canonical query/name fields'); - } - return input as MutationInput; -} - export interface OmnigraphOptions { /** Base URL of the omnigraph-server. e.g. `http://127.0.0.1:8080`. */ baseUrl: string; @@ -124,11 +98,9 @@ export default class Omnigraph { } /** - * Run a GQ read query. Canonical read endpoint as of server 0.6.0 - * (successor to `read`). Read-only. - * - * Identical response shape to `og.read()`; the canonical field names are - * `query` / `name` (vs. legacy `querySource` / `queryName`). + * Run a GQ read query (`POST /query`). Read-only. Field names are + * `query` / `name`; `params` is a free-form map matched to `$var` + * placeholders in the query source. */ query(input: QueryInput, opts: CallOptions = {}): Promise { return this.t.request('POST', '/query', { @@ -140,8 +112,8 @@ export default class Omnigraph { } /** - * Run a GQ mutation. Canonical write endpoint as of server 0.6.0 - * (successor to `change`). **Destructive** — branch is updated atomically. + * Run a GQ mutation (`POST /mutate`). **Destructive** — branch is updated + * atomically. * * **Idempotency**: design queries with `@unique` constraints or * `update ... where` clauses to allow safe retry. Blind `insert` without @@ -156,51 +128,9 @@ export default class Omnigraph { } /** - * Run a GQ read query. Read-only. - * - * @deprecated Server 0.6.0 introduces {@link Omnigraph.query} as the - * canonical successor. `POST /read` still works but the server emits - * `Deprecation: true` and `Link: ; rel="successor-version"` - * response headers. Migrate to `og.query()`; the field names there are - * `query` / `name` instead of `querySource` / `queryName`. - */ - read(input: ReadInput, opts: CallOptions = {}): Promise { - return this.t.request('POST', '/read', { - body: input, - signal: opts.signal, - opaqueBodyKeys: OPAQUE_PARAMS, - opaqueResponseKeys: OPAQUE_READ_RESPONSE, - }); - } - - /** - * Run a GQ mutation. Returns counts of nodes/edges affected and produces - * a new commit on success. **Destructive** — branch is updated atomically. - * - * **Idempotency**: design queries with `@unique` constraints or - * `update ... where` clauses to allow safe retry. Blind `insert` without - * unique keys can duplicate on retry. - * - * @deprecated Server 0.6.0 introduces {@link Omnigraph.mutate} as the - * canonical successor. `POST /change` still works but the server emits - * `Deprecation: true` and `Link: ; rel="successor-version"` - * response headers. Accepts both the old SDK fields (`querySource` / - * `queryName`) and the canonical fields (`query` / `name`); the wire request - * is normalized to the server 0.6 shape. - */ - change(input: ChangeInput, opts: CallOptions = {}): Promise { - return this.t.request('POST', '/change', { - body: normalizeChangeInput(input), - signal: opts.signal, - opaqueBodyKeys: OPAQUE_PARAMS, - }); - } - - /** - * Bulk-load NDJSON into a branch. Canonical write-load endpoint as of - * server 0.7.0 (successor to `ingest`). **Use `mode: 'merge'` for - * at-least-once safety** — retries upsert by `@key` instead of duplicating - * rows. + * Bulk-load NDJSON into a branch. The canonical write-load endpoint. + * **Use `mode: 'merge'` for at-least-once safety** — retries upsert by + * `@key` instead of duplicating rows. * * **Branch creation is opt-in.** Without `from`, the target `branch` must * already exist — a missing branch is a {@link NotFoundError} (404), never an @@ -210,18 +140,6 @@ export default class Omnigraph { return this.t.request('POST', '/load', { body: input, signal: opts.signal }); } - /** - * Bulk-ingest NDJSON. Identical request/response shape to {@link Omnigraph.load}. - * - * @deprecated Server 0.7.0 introduces {@link Omnigraph.load} as the canonical - * successor. `POST /ingest` still works (kept indefinitely as a shim) but the - * server emits `Deprecation: true` and `Link: ; rel="successor-version"` - * response headers. Migrate to `og.load()`; the shapes are identical. - */ - ingest(input: IngestInput, opts: CallOptions = {}): Promise { - return this.t.request('POST', '/ingest', { body: input, signal: opts.signal }); - } - /** * Get a snapshot of the latest commit on a branch. Read-only. */ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2121b19..2763248 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -47,8 +47,6 @@ export type { SchemaApplyInput, // Operations Change, - ChangeInput, - LegacyChangeInput, MutationInput, ExportInput, Health, @@ -57,7 +55,6 @@ export type { IngestTable, QueryInput, Read, - ReadInput, ReadTarget, Snapshot, SnapshotTable, @@ -76,7 +73,7 @@ export type { } from './types'; // Runtime enum constants — also valid as types via TS declaration merging. -// Use as values: `og.ingest({ ..., mode: LoadMode.MERGE })`. +// Use as values: `og.load({ ..., mode: LoadMode.MERGE })`. // Use as types: `function check(c: ErrorCode) { ... }`. export { BranchMergeOutcome, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c624977..118b4f5 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -35,7 +35,6 @@ import type { QueryCatalogEntry as QueryCatalogEntryOutput, QueryRequest, ReadOutput, - ReadRequest, ReadTargetOutput, SchemaApplyOutput, SchemaApplyRequest, @@ -87,26 +86,10 @@ export type ManifestConflict = Camelize; export type BranchCreateInput = Camelize; export type BranchMergeInput = Camelize; export type MutationInput = Camelize; -export type LegacyChangeInput = { - /** Target branch. Defaults to `main`. */ - branch?: string | null; - /** Name of the mutation to run when `querySource` declares multiple. */ - queryName?: string | null; - /** JSON object whose keys match the mutation's declared parameters. */ - params?: unknown; - /** Legacy GQ mutation source field accepted by deprecated `og.change()`. */ - querySource: string; - query?: never; - name?: never; -}; -export type ChangeInput = - | (MutationInput & { querySource?: never; queryName?: never }) - | LegacyChangeInput; export type ExportInput = Camelize; export type IngestInput = Camelize; export type QueryInput = Camelize; export type InvokeQueryInput = Camelize; -export type ReadInput = Camelize; export type SchemaApplyInput = Camelize; // Enums and discriminators are unchanged (no snake-case keys to convert). diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts index fb51c0e..fa8a862 100644 --- a/packages/sdk/test/client.test.ts +++ b/packages/sdk/test/client.test.ts @@ -33,39 +33,6 @@ describe('top-level client operations', () => { expect(calls[0]?.url).toBe('http://x/graphs/g/snapshot'); }); - it('ingest sends NDJSON data via JSON body and camelCases the response', async () => { - const { fetch, calls } = stubFetch({ - body: { - actor_id: null, - base_branch: 'main', - branch: 'feat', - branch_created: true, - mode: 'merge', - tables: [ - { table_key: 'node:Person', rows_loaded: 2 }, - ], - uri: 's3://x', - }, - }); - const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - const r = await og.ingest({ - branch: 'feat', - from: 'main', - mode: 'merge', - data: '{"type":"Person","data":{"name":"A"}}\n', - }); - expect(calls[0]?.method).toBe('POST'); - expect(calls[0]?.url).toBe('http://x/graphs/g/ingest'); - const body = JSON.parse(calls[0]?.body ?? '{}'); - expect(body.branch).toBe('feat'); - expect(body.from).toBe('main'); - expect(body.mode).toBe('merge'); - expect(body.data).toContain('Person'); - expect(r.branchCreated).toBe(true); - expect(r.tables[0]?.tableKey).toBe('node:Person'); - expect(r.tables[0]?.rowsLoaded).toBe(2); - }); - it('load sends POST /load and tolerates a null base_branch', async () => { const { fetch, calls } = stubFetch({ body: { @@ -159,50 +126,6 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => { expect(body.params).toEqual({ name: 'Frank' }); expect(r.affectedNodes).toBe(1); }); - - it('og.change accepts canonical query/name fields and emits canonical wire fields', async () => { - const { fetch, calls } = stubFetch({ - body: { actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }, - }); - const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - await og.change({ query: 'query q() { insert X {} }', name: 'q', branch: 'main' }); - const body = JSON.parse(calls[0]?.body ?? '{}'); - expect(body.query).toBe('query q() { insert X {} }'); - expect(body.name).toBe('q'); - expect('query_source' in body).toBe(false); - expect('query_name' in body).toBe(false); - }); - - it('og.change accepts legacy querySource/queryName fields and normalizes to canonical wire fields', async () => { - const { fetch, calls } = stubFetch({ - body: { actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }, - }); - const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - await og.change({ - querySource: 'query q() { insert X {} }', - queryName: 'q', - branch: 'main', - }); - const body = JSON.parse(calls[0]?.body ?? '{}'); - expect(calls[0]?.url).toBe('http://x/graphs/g/change'); - expect(body.query).toBe('query q() { insert X {} }'); - expect(body.name).toBe('q'); - expect('query_source' in body).toBe(false); - expect('query_name' in body).toBe(false); - }); - - it('og.change rejects mixed canonical and legacy mutation field families', async () => { - const { fetch } = stubFetch({ - body: { actor_id: null, affected_edges: 0, affected_nodes: 1, branch: 'main', query_name: 'q' }, - }); - const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - expect(() => - og.change({ - query: 'query q() { insert X {} }', - querySource: 'query q() { insert X {} }', - } as never), - ).toThrow(/either query\/name or querySource\/queryName/); - }); }); describe('og.graph(id)', () => { diff --git a/packages/sdk/test/e2e.test.ts b/packages/sdk/test/e2e.test.ts index c148aa9..d24d04d 100644 --- a/packages/sdk/test/e2e.test.ts +++ b/packages/sdk/test/e2e.test.ts @@ -150,11 +150,11 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { }); describe('queries', () => { - it('parameterized read returns matching row with camelCased fields', async () => { - const r = await og.read({ - querySource: + it('parameterized query returns matching row with camelCased fields', async () => { + const r = await og.query({ + query: 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }', - queryName: 'find', + name: 'find', params: { name: 'Alice' }, branch: 'main', }); @@ -166,21 +166,21 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { expect(nameField).toBe('Alice'); }); - it('parameterless read returns multiple rows', async () => { - const r = await og.read({ - querySource: + it('parameterless query returns multiple rows', async () => { + const r = await og.query({ + query: 'query adults() { match { $p: Person\n$p.age > 25 } return { $p.name, $p.age } }', - queryName: 'adults', + name: 'adults', branch: 'main', }); expect((r.rows as unknown[]).length).toBeGreaterThanOrEqual(2); }); - it('change inserts a row on a fresh branch', async () => { - const branch = `e2e-change-${Date.now()}`; + it('mutate inserts a row on a fresh branch', async () => { + const branch = `e2e-mutate-${Date.now()}`; branchesToCleanup.push(branch); await og.branches.create({ name: branch, from: 'main' }); - const ch = await og.change({ + const ch = await og.mutate({ query: 'query addPerson($name: String, $age: I32) { insert Person { name: $name, age: $age } }', name: 'addPerson', @@ -191,10 +191,10 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { }); }); - describe('load / ingest', () => { + describe('load', () => { // `from` is mandatory for a missing branch under 0.7.0 — without it the - // server returns 404 (no implicit fork). Both tests pass `from: 'main'`. - it('load (canonical) merge-mode forks a branch and writes rows', async () => { + // server returns 404 (no implicit fork). This test passes `from: 'main'`. + it('merge-mode forks a branch and writes rows', async () => { const branch = `e2e-load-${Date.now()}`; const name = `e2e-Carol-${Date.now()}`; branchesToCleanup.push(branch); @@ -207,38 +207,15 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { expect(result.branch).toBe(branch); expect(result.tables.length).toBeGreaterThan(0); - const r = await og.read({ - querySource: + const r = await og.query({ + query: 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }', - queryName: 'find', + name: 'find', params: { name }, branch, }); expect((r.rows as unknown[]).length).toBe(1); }); - - it('ingest (deprecated alias) merge mode creates branch and writes rows', async () => { - const branch = `e2e-ingest-${Date.now()}`; - const dianaName = `e2e-Diana-${Date.now()}`; - branchesToCleanup.push(branch); - const result = await og.ingest({ - branch, - from: 'main', - mode: LoadMode.MERGE, - data: JSON.stringify({ type: 'Person', data: { name: dianaName, age: 40 } }) + '\n', - }); - expect(result.branch).toBe(branch); - expect(result.tables.length).toBeGreaterThan(0); - - const r = await og.read({ - querySource: - 'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }', - queryName: 'find', - params: { name: dianaName }, - branch, - }); - expect((r.rows as unknown[]).length).toBe(1); - }); }); describe('commits', () => { @@ -293,7 +270,7 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => { it('malformed query surfaces BadRequestError', async () => { await expect( - og.read({ querySource: 'this is not gq', queryName: 'broken', branch: 'main' }), + og.query({ query: 'this is not gq', name: 'broken', branch: 'main' }), ).rejects.toBeInstanceOf(BadRequestError); }); }); diff --git a/packages/sdk/test/enums.test.ts b/packages/sdk/test/enums.test.ts index 7955d82..d684870 100644 --- a/packages/sdk/test/enums.test.ts +++ b/packages/sdk/test/enums.test.ts @@ -53,7 +53,7 @@ describe('runtime enum constants', () => { const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); // Mode accepts LoadModeType; LoadMode.MERGE is the value. const mode: LoadModeType = LoadMode.MERGE; - await og.ingest({ branch: 'feat', data: '{}\n', mode }); + await og.load({ branch: 'feat', data: '{}\n', mode }); const body = JSON.parse(calls[0]?.body ?? '{}'); expect(body.mode).toBe('merge'); }); diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts index c834ce0..23049dd 100644 --- a/packages/sdk/test/errors.test.ts +++ b/packages/sdk/test/errors.test.ts @@ -54,7 +54,7 @@ describe('error dispatcher', () => { }); const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); try { - await og.change({ query: 'insert Person { name: "x" }' }); + await og.mutate({ query: 'insert Person { name: "x" }' }); throw new Error('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(ConflictError); diff --git a/packages/sdk/test/opaque.test.ts b/packages/sdk/test/opaque.test.ts index fbecdd0..8e72eb1 100644 --- a/packages/sdk/test/opaque.test.ts +++ b/packages/sdk/test/opaque.test.ts @@ -23,7 +23,7 @@ describe('opaque keys (GQ params, rows, columns)', () => { expect(out).toEqual({ rowCount: 1, rows: { user_id: 1 } }); }); - it('og.read sends params with caller-supplied keys verbatim', async () => { + it('og.query sends params with caller-supplied keys verbatim', async () => { const { fetch, calls } = stubFetch({ body: { query_name: 'q', @@ -34,16 +34,17 @@ describe('opaque keys (GQ params, rows, columns)', () => { }, }); const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - await og.read({ + await og.query({ branch: 'main', - querySource: 'query q($userId: I32) { match { $u: User { id: $userId } } return { $u.name } }', + query: 'query q($userId: I32) { match { $u: User { id: $userId } } return { $u.name } }', params: { userId: 42, $extraName: 'x' }, }); const body = JSON.parse(calls[0]?.body ?? '{}'); + expect(calls[0]?.url).toBe('http://x/graphs/g/query'); expect(body.params).toEqual({ userId: 42, $extraName: 'x' }); }); - it('og.change sends params with caller-supplied keys verbatim', async () => { + it('og.mutate sends params with caller-supplied keys verbatim', async () => { const { fetch, calls } = stubFetch({ body: { actor_id: null, @@ -54,16 +55,17 @@ describe('opaque keys (GQ params, rows, columns)', () => { }, }); const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - await og.change({ + await og.mutate({ branch: 'feat', query: 'mutation m($keyName: String) { ... }', params: { keyName: 'value' }, }); const body = JSON.parse(calls[0]?.body ?? '{}'); + expect(calls[0]?.url).toBe('http://x/graphs/g/mutate'); expect(body.params).toEqual({ keyName: 'value' }); }); - it('og.read preserves rows shape on response (no camelization)', async () => { + it('og.query preserves rows shape on response (no camelization)', async () => { const { fetch } = stubFetch({ body: { query_name: 'q', @@ -77,7 +79,7 @@ describe('opaque keys (GQ params, rows, columns)', () => { }, }); const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }); - const r = await og.read({ branch: 'main', querySource: 'query q() { ... }' }); + const r = await og.query({ branch: 'main', query: 'query q() { ... }' }); expect(r.queryName).toBe('q'); // top-level keys still camelCased expect(r.rowCount).toBe(2); expect(r.columns).toEqual(['user_id', 'full_name']); // opaque diff --git a/packages/sdk/test/stream.test.ts b/packages/sdk/test/stream.test.ts index d42b2cd..d6f7a57 100644 --- a/packages/sdk/test/stream.test.ts +++ b/packages/sdk/test/stream.test.ts @@ -6,7 +6,7 @@ describe('export streaming', () => { it('yields rows from NDJSON body, preserving user-schema keys inside `data`', async () => { // `data` is user-schema-controlled. Keys like `first_name` or `table_key` // are caller-defined and must survive the snake/camel boundary unchanged, - // so that `og.ingest()` of the exported NDJSON round-trips byte-for-byte. + // so that `og.load()` of the exported NDJSON round-trips byte-for-byte. const ndjson = '{"type":"Person","data":{"first_name":"Alice","is_active":true}}\n' + '{"edge":"WorksAt","from":"Alice","to":"Acme","data":{"start_year":2020}}\n'; diff --git a/packages/sdk/test/transport.test.ts b/packages/sdk/test/transport.test.ts index f69310a..81fddb4 100644 --- a/packages/sdk/test/transport.test.ts +++ b/packages/sdk/test/transport.test.ts @@ -139,8 +139,8 @@ describe('transport graphId prefixing', () => { expect(calls[1]?.url).toBe('http://x/graphs/alpha/schema/apply'); }); - it('prefixes /read, /query, /change, /mutate, /load, /ingest, /snapshot, /export under /graphs/{graphId}', async () => { - const ingestBody = { + it('prefixes /query, /mutate, /load, /snapshot, /export under /graphs/{graphId}', async () => { + const loadBody = { actor_id: null, base_branch: 'main', branch: 'main', @@ -151,31 +151,22 @@ describe('transport graphId prefixing', () => { }; const { fetch, calls } = stubFetch([ { body: { rows: [], columns: [] } }, - { body: { rows: [], columns: [] } }, - { body: { affected_nodes: 0, affected_edges: 0 } }, { body: { affected_nodes: 0, affected_edges: 0 } }, - { body: ingestBody }, - { body: ingestBody }, + { body: loadBody }, { body: { branch: 'main', tables: [] } }, { body: '', headers: { 'content-type': 'application/x-ndjson' } }, ]); const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'alpha', fetch }); - await og.read({ querySource: 'query q() {}' }); await og.query({ query: 'query q() {}' }); - await og.change({ query: 'query q() {}' }); await og.mutate({ query: 'query q() {}' }); await og.load({ branch: 'main', mode: 'merge', data: '{}\n' }); - await og.ingest({ branch: 'main', mode: 'merge', data: '{}\n' }); await og.snapshot(); for await (const _ of og.export({ branch: 'main' })) void _; - expect(calls[0]?.url).toBe('http://x/graphs/alpha/read'); - expect(calls[1]?.url).toBe('http://x/graphs/alpha/query'); - expect(calls[2]?.url).toBe('http://x/graphs/alpha/change'); - expect(calls[3]?.url).toBe('http://x/graphs/alpha/mutate'); - expect(calls[4]?.url).toBe('http://x/graphs/alpha/load'); - expect(calls[5]?.url).toBe('http://x/graphs/alpha/ingest'); - expect(calls[6]?.url).toBe('http://x/graphs/alpha/snapshot'); - expect(calls[7]?.url).toBe('http://x/graphs/alpha/export'); + expect(calls[0]?.url).toBe('http://x/graphs/alpha/query'); + expect(calls[1]?.url).toBe('http://x/graphs/alpha/mutate'); + expect(calls[2]?.url).toBe('http://x/graphs/alpha/load'); + expect(calls[3]?.url).toBe('http://x/graphs/alpha/snapshot'); + expect(calls[4]?.url).toBe('http://x/graphs/alpha/export'); }); it('never prefixes /healthz', async () => { diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts index d289261..a791201 100644 --- a/scripts/check-coverage.ts +++ b/scripts/check-coverage.ts @@ -162,7 +162,19 @@ function flattenSpecPath(p: string): string { return m ? m[1]! : p; } +// Spec operations the SDK intentionally does NOT bind. The server still serves +// these (deprecated shims kept indefinitely), but the SDK dropped the +// deprecated aliases in the 0.7.0 clean break — `query` / `mutate` / `load` are +// the canonical surface. Listed explicitly (and logged below, never silently) +// so the check stays honest: a NON-allowlisted missing binding still fails. +const INTENTIONALLY_UNBOUND = new Map([ + ['POST /read', 'removed alias — use og.query() (POST /query)'], + ['POST /change', 'removed alias — use og.mutate() (POST /mutate)'], + ['POST /ingest', 'removed alias — use og.load() (POST /load)'], +]); + const errors: string[] = []; +const intentionallyUnbound: string[] = []; const expectedByKey = new Map(); for (const e of expected) { @@ -194,9 +206,24 @@ for (const c of calls) { for (const e of expected) { const key = `${e.method} ${normalizePath(flattenSpecPath(e.path))}`; - if (!seenInSdk.has(key)) { - errors.push(`No SDK binding for spec operation ${e.method} ${e.path}`); + if (seenInSdk.has(key)) continue; + const reason = INTENTIONALLY_UNBOUND.get(key); + if (reason) { + intentionallyUnbound.push(`${e.method} ${e.path} — ${reason}`); + continue; } + errors.push(`No SDK binding for spec operation ${e.method} ${e.path}`); +} + +// Surface the intentionally-unbound deprecated shims loudly so dropping a +// binding is never silent. Fail if the allowlist is stale (an entry no longer +// in the spec) — the spec dropping a shim should prompt removing the allowlist. +const staleAllow = [...INTENTIONALLY_UNBOUND.keys()].filter((key) => { + const [method, flat] = key.split(' '); + return !expected.some((e) => `${e.method} ${flattenSpecPath(e.path)}` === `${method} ${flat}`); +}); +for (const key of staleAllow) { + errors.push(`INTENTIONALLY_UNBOUND lists '${key}', but the spec no longer defines it — remove it from check-coverage.ts`); } if (errors.length > 0) { @@ -209,6 +236,11 @@ if (errors.length > 0) { process.exit(1); } +if (intentionallyUnbound.length > 0) { + console.log('Intentionally unbound (deprecated server shims, not exposed by the SDK):'); + for (const u of intentionallyUnbound) console.log(` · ${u}`); +} + console.log( - `Coverage check passed: ${calls.length} call sites bound to ${expected.length} spec operations; query params validated.`, + `Coverage check passed: ${calls.length} call sites bound to ${expected.length - intentionallyUnbound.length} of ${expected.length} spec operations (${intentionallyUnbound.length} intentionally unbound); query params validated.`, ); From e6b33e67bfdc3a8e0267b936ee8c2d8656c6638e Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 16 Jun 2026 17:59:45 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(mcp)!:=20drop=20schema=5Fapply=20tool?= =?UTF-8?q?=20=E2=80=94=20cluster=20graphs=20reject=20HTTP=20schema=20appl?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omnigraph-server 0.7.0 is cluster-only, and a cluster-managed graph rejects POST /graphs/{id}/schema/apply with 409 ("schema apply disabled for cluster-backed serving"). The MCP previously exposed a `schema_apply` tool that called og.schema.apply() and advertised idempotent HTTP migration — on the only supported deployment it could never succeed, and the INSTRUCTIONS steered agents to it. Schema evolution on a cluster graph is an operator action (`omnigraph cluster apply`), outside the data-plane HTTP API this MCP wraps. So: - remove the `schema_apply` MCP tool; keep `schema_get` (read-only). - rewrite the INSTRUCTIONS norms + best-practices pointer accordingly. - README/test updated; added a test asserting schema_apply is NOT exposed. The SDK keeps og.schema.apply() — it is a faithful binding for the spec endpoint and correctly surfaces the 409 (covered by a unit test); the README now caveats that it is rejected on a cluster-managed graph. Coverage unaffected (/schema/apply still bound by the SDK method). Full gate green. --- packages/mcp/README.md | 3 ++- packages/mcp/src/server.ts | 33 ++++++++++----------------- packages/mcp/test/server.test.ts | 38 ++++---------------------------- packages/sdk/README.md | 11 +++------ 4 files changed, 21 insertions(+), 64 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index aaad566..b56cb36 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -72,7 +72,8 @@ Mutating (`destructiveHint: true` where appropriate — hosts should surface con | `branches_create` | Create a new branch | | `branches_delete` | Delete a branch | | `branches_merge` | Merge `source` into `target` | -| `schema_apply` | Apply a schema migration. Optional `allowDataLoss: true` hard-drops column data for destructive steps; leave unset unless the plan was reviewed. | + +There is **no `schema_apply` tool**: a cluster-managed graph rejects HTTP schema apply (409). Schema is read-only here (`schema_get`); evolve it via `omnigraph cluster apply`. ### Resources diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 5f56246..bd315bb 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -4,9 +4,10 @@ // // Tools mutate or query the live database. Read-only tools (query, snapshot, // branches.list, commits.list, schema.get, health) carry no destructive -// side effects. Mutating tools (mutate, load, schema.apply, branch -// create/delete/merge) are annotated with `destructiveHint: true` so MCP -// hosts can surface a confirmation UI. +// side effects. Mutating tools (mutate, load, branch create/delete/merge) are +// annotated with `destructiveHint: true` so MCP hosts can surface a +// confirmation UI. Schema is read-only here (`schema_get`): a cluster-managed +// graph evolves its schema via `omnigraph cluster apply`, not over HTTP. // // Resources are an alternative read surface — agents that prefer to *read* // the schema or a branch snapshot rather than *call* a tool can use them. @@ -28,7 +29,7 @@ ALWAYS read \`omnigraph://schema\` (or call \`schema_get\`) FIRST, before any qu After schema, consult the matching best-practices resource for the task at hand: - omnigraph://best-practices/queries — before .gq queries (query/mutate) - omnigraph://best-practices/data — before load (mode selection, branch loop) - - omnigraph://best-practices/schema — before schema_apply + - omnigraph://best-practices/schema — to understand the .pg schema before writing - omnigraph://best-practices/remote-ops — after any 504 or unexpected error - omnigraph://best-practices/search — before nearest/bm25/rrf queries @@ -40,8 +41,8 @@ Workflow norms (violating these breaks things or silently corrupts data): 4. \`load mode: "merge"\` upserts by @key (idempotent — use this for at-least-once pipelines). \`"overwrite"\` truncates the branch. \`"append"\` fails on key collision. 5. Verify every write. \`commits_list\` head BEFORE and AFTER. If identical, the write did not land. 504s do not mean failure — the server may have committed after the proxy dropped the response. 6. Append-only types (Signal, Claim, Decision, Event, Interaction, Policy, Outcome, MarketingElement) duplicate on blind retry. Pointer types (Org, Person, Opportunity, Channel, Actor, ActionItem, Artifact, Meeting, Technology, Campaign, UseCase) dedupe via @key. -7. Risky/large writes: \`branches_create\` from main → \`load\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`. \`schema_apply\` skips branches: it is main-only and rejects open feature branches. -8. \`schema_apply\` is destructive and has no undo. Use \`schema_get\` + a local diff first. Non-nullable property adds require add-optional → backfill → tighten in two applies. +7. Risky/large writes: \`branches_create\` from main → \`load\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`. +8. Schema is read-only over this MCP. \`schema_get\` returns the active .pg source; there is no \`schema_apply\` tool. A cluster-managed graph rejects HTTP schema apply (409) — schema changes go through \`omnigraph cluster apply\` (an operator/CLI action), not an agent tool. Date format: ISO strings on \`mutate\` params; integer days-since-epoch in load JSONL \`Date\` fields. \`DateTime\` is ISO on both. @@ -335,21 +336,11 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { }, ); - server.registerTool( - 'schema_apply', - { - title: 'Apply schema migration', - description: - 'Apply a new .pg schema as a migration. Idempotent: applying an unchanged schema returns applied=false. ' + - '`allowDataLoss` hard-drops column data for destructive migration steps; leave false unless the plan was reviewed.', - inputSchema: { schemaSource: z.string().min(1), allowDataLoss: z.boolean().optional() }, - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, - }, - async ({ schemaSource, allowDataLoss }) => { - const r = await og.schema.apply({ schemaSource, allowDataLoss }); - return { content: jsonText(r) }; - }, - ); + // NOTE: no `schema_apply` tool. omnigraph-server 0.7.0 is cluster-only, and a + // cluster-managed graph rejects `POST /graphs/{id}/schema/apply` with 409 — + // schema is evolved declaratively via `omnigraph cluster apply`, an operator + // action outside the HTTP API this MCP wraps. Use `schema_get` to read the + // active schema; route migrations through the cluster workflow. // ---------- Resources -------------------------------------------------- // A schema-shaped read surface for agents that prefer reading over calling. diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index d9ae74c..f44bb04 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -111,7 +111,6 @@ describe('omnigraph-mcp server', () => { 'load', 'mutate', 'query', - 'schema_apply', 'schema_get', 'snapshot', ].sort(), @@ -125,7 +124,6 @@ describe('omnigraph-mcp server', () => { expect(byName.get('load')?.annotations?.destructiveHint).toBe(true); expect(byName.get('branches_delete')?.annotations?.destructiveHint).toBe(true); expect(byName.get('branches_merge')?.annotations?.destructiveHint).toBe(true); - expect(byName.get('schema_apply')?.annotations?.destructiveHint).toBe(true); expect(byName.get('snapshot')?.annotations?.readOnlyHint).toBe(true); expect(byName.get('schema_get')?.annotations?.readOnlyHint).toBe(true); }); @@ -321,38 +319,10 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as expect(observedBody?.branch).toBeUndefined(); }); - it('schema_apply forwards allowDataLoss as allow_data_loss', async () => { - let observedBody: Record | undefined; - const recordingFetch: typeof globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - const path = flatPath(url); - const method = init?.method ?? 'GET'; - if (method === 'POST' && path === '/schema/apply') { - observedBody = JSON.parse(typeof init?.body === 'string' ? init.body : '{}'); - return new Response( - JSON.stringify({ applied: true, manifest_version: 2, step_count: 1, steps: [], supported: true, uri: 's3://x' }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); - }) as unknown as typeof globalThis.fetch; - - const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: recordingFetch }); - const client = new Client({ name: 'test', version: '0.0.0' }); - const [clientT, serverT] = InMemoryTransport.createLinkedPair(); - await Promise.all([server.connect(serverT), client.connect(clientT)]); - - await client.callTool({ - name: 'schema_apply', - arguments: { - schemaSource: 'node Foo { id: String @key }', - allowDataLoss: true, - }, - }); - expect(observedBody).toEqual({ - schema_source: 'node Foo { id: String @key }', - allow_data_loss: true, - }); + it('does not expose a schema_apply tool (cluster graphs reject HTTP schema apply)', async () => { + const { client } = await setup(); + const names = (await client.listTools()).tools.map((t) => t.name); + expect(names).not.toContain('schema_apply'); }); it('rejects calls with missing required input', async () => { diff --git a/packages/sdk/README.md b/packages/sdk/README.md index fcce67f..d719598 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -107,15 +107,10 @@ The iterator lazily issues `POST /export` on first iteration and cancels the ups ```ts const { schemaSource } = await og.schema.get(); // .pg source -await og.schema.apply({ schemaSource: nextSchema }); // migrate - -// Hard-drop column data instead of soft-dropping it (defaults to false; matches -// the CLI's --allow-data-loss). Soft drops remain reversible via time travel; -// hard drops are not. Use only when the migration plan includes intentional -// data deletions you've already reviewed. -await og.schema.apply({ schemaSource: nextSchema, allowDataLoss: true }); ``` +> **`og.schema.apply()` is rejected on a cluster-managed graph (409 → `ConflictError`).** A 0.7.0 server is cluster-only and evolves schema declaratively via `omnigraph cluster apply` (an operator action), not over HTTP. The method remains in the SDK as a faithful binding for `POST /schema/apply` (and surfaces the 409), but on a cluster server it will not migrate. Drive schema changes through the cluster workflow. + ### Snapshots and commits ```ts @@ -196,7 +191,7 @@ Omnigraph is a database; idempotency belongs in the schema (`@key`, `@unique`), | `og.branches.create({ name })` | Throws `ConflictError` on retry (branch exists). Catch and treat as success. | | `og.branches.merge({ source, target })` | Idempotent — re-merge yields `outcome: 'already_up_to_date'`. | | `og.branches.delete(name)` | Idempotent — delete-of-deleted is a no-op. | -| `og.schema.apply({ schemaSource })` | Idempotent — unchanged schema returns `applied: false`. | +| `og.schema.apply({ schemaSource })` | **Rejected (409) on a cluster-managed graph** — evolve schema via `omnigraph cluster apply`, not over HTTP. | | `og.load({ data, mode: 'merge' })` | **Idempotent** — use this mode for at-least-once pipelines. Requires `@key` constraints. | | `og.load({ data, mode: 'overwrite' })` | Idempotent — same input → same final state. | | `og.load({ data, mode: 'append' })` | **Not idempotent** — blind insert. Avoid for retry-prone callers. |