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..b56cb36 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
@@ -56,31 +56,30 @@ 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 |
| `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):
| 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`. |
-| `ingest` | Bulk-ingest NDJSON (`mode: 'merge'` for idempotency) |
+| `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` |
-| `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
- `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..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",
@@ -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..bd315bb 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -2,11 +2,12 @@
// 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
-// 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.
@@ -23,31 +24,31 @@ 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/schema — before schema_apply
+ - omnigraph://best-practices/queries — before .gq queries (query/mutate)
+ - omnigraph://best-practices/data — before load (mode selection, branch loop)
+ - 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
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.
-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 \`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-cookbooks/tree/main/skills/omnigraph-best-practices`;
+Depth: https://github.com/ModernRelay/omnigraph/tree/main/skills/omnigraph`;
export interface CreateServerOptions {
baseUrl: string;
@@ -55,9 +56,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). */
@@ -65,25 +67,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) }];
@@ -93,23 +76,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,
@@ -197,36 +163,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',
{
@@ -262,10 +198,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 },
},
@@ -333,32 +269,13 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
);
server.registerTool(
- 'change',
+ 'load',
{
- 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(
- 'ingest',
- {
- title: 'Bulk-ingest NDJSON',
+ title: 'Bulk-load NDJSON',
description:
'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.',
+ '`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(),
@@ -368,7 +285,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
},
async ({ branch, from, mode, data }) => {
- const r = await og.ingest({ branch, from, mode, data });
+ const r = await og.load({ branch, from, mode, data });
return { content: jsonText(r) };
},
);
@@ -419,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.
@@ -474,8 +381,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 +435,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..f44bb04 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 () => {
@@ -96,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',
].sort(),
@@ -116,12 +121,9 @@ 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('ingest')?.annotations?.destructiveHint).toBe(true);
+ 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('read')?.annotations?.readOnlyHint).toBe(true);
expect(byName.get('snapshot')?.annotations?.readOnlyHint).toBe(true);
expect(byName.get('schema_get')?.annotations?.readOnlyHint).toBe(true);
});
@@ -133,16 +135,16 @@ 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 () => {
+ 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',
},
@@ -154,49 +156,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 () => {
- 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 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', 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',
- 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 () => {
+ 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 = new URL(url).pathname;
+ 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' }),
@@ -206,13 +172,13 @@ 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)]);
await client.callTool({
- name: 'change',
+ name: 'mutate',
arguments: {
query: 'query q() { insert X {} }',
name: 'q',
@@ -226,18 +192,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' });
@@ -300,7 +254,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 +268,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,
});
@@ -326,13 +280,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 = new URL(url).pathname;
+ 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({
@@ -349,7 +303,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,
});
@@ -358,51 +312,23 @@ 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();
});
- 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 = new URL(url).pathname;
- 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', 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 () => {
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 1203902..d719598 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,9 +34,11 @@ 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.
-> **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.
+> **`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`.
+
+> **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
@@ -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 (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
```ts
@@ -102,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
@@ -142,13 +142,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`:
@@ -185,23 +187,21 @@ 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.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.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.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. |
+| `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`.
-
-## 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({
@@ -211,7 +211,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:
@@ -223,7 +223,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 +232,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..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;
@@ -64,12 +38,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,
@@ -122,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', {
@@ -138,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
@@ -154,52 +128,16 @@ export default class Omnigraph {
}
/**
- * Run a GQ read query. Read-only.
+ * 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.
*
- * @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-ingest NDJSON. **Use `mode: 'merge'` for at-least-once safety** —
- * ensures 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.
*/
- ingest(input: IngestInput, opts: CallOptions = {}): Promise {
- return this.t.request('POST', '/ingest', { body: input, signal: opts.signal });
+ load(input: IngestInput, opts: CallOptions = {}): Promise {
+ return this.t.request('POST', '/load', { 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..2763248 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.
@@ -46,8 +47,6 @@ export type {
SchemaApplyInput,
// Operations
Change,
- ChangeInput,
- LegacyChangeInput,
MutationInput,
ExportInput,
Health,
@@ -56,7 +55,6 @@ export type {
IngestTable,
QueryInput,
Read,
- ReadInput,
ReadTarget,
Snapshot,
SnapshotTable,
@@ -75,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/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/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/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..fa8a862 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,42 +28,33 @@ 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 () => {
+ it('load sends POST /load and tolerates a null base_branch', async () => {
const { fetch, calls } = stubFetch({
body: {
actor_id: null,
- base_branch: 'main',
- branch: 'feat',
- branch_created: true,
+ base_branch: null,
+ branch: 'main',
+ branch_created: false,
mode: 'merge',
- tables: [
- { table_key: 'node:Person', rows_loaded: 2 },
- ],
+ tables: [],
uri: 's3://x',
},
});
- const og = new Omnigraph({ baseUrl: 'http://x', fetch });
- const r = await og.ingest({
- branch: 'feat',
- from: 'main',
+ 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/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);
+ expect(calls[0]?.url).toBe('http://x/graphs/g/load');
+ expect(r.baseBranch).toBeNull();
+ expect(r.branchCreated).toBe(false);
});
});
@@ -78,7 +69,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 +77,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 +91,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 +110,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 +118,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');
@@ -135,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', 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', 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(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', 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)', () => {
@@ -187,13 +134,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 +169,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 +203,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..d24d04d 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' });
@@ -113,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',
});
@@ -129,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',
@@ -154,25 +191,27 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
});
});
- describe('ingest', () => {
- it('merge mode creates branch and writes rows', async () => {
- const branch = `e2e-ingest-${Date.now()}`;
- const dianaName = `e2e-Diana-${Date.now()}`;
+ describe('load', () => {
+ // `from` is mandatory for a missing branch under 0.7.0 — without it the
+ // 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);
- const result = await og.ingest({
+ const result = await og.load({
branch,
from: 'main',
mode: LoadMode.MERGE,
- data: JSON.stringify({ type: 'Person', data: { name: dianaName, age: 40 } }) + '\n',
+ 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:
+ const r = await og.query({
+ query:
'query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }',
- queryName: 'find',
- params: { name: dianaName },
+ name: 'find',
+ params: { name },
branch,
});
expect((r.rows as unknown[]).length).toBe(1);
@@ -231,64 +270,8 @@ 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);
});
});
-
- // 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..d684870 100644
--- a/packages/sdk/test/enums.test.ts
+++ b/packages/sdk/test/enums.test.ts
@@ -50,10 +50,10 @@ 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 });
+ 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 4e060ca..23049dd 100644
--- a/packages/sdk/test/errors.test.ts
+++ b/packages/sdk/test/errors.test.ts
@@ -52,9 +52,9 @@ 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" }' });
+ await og.mutate({ query: 'insert Person { name: "x" }' });
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(ConflictError);
@@ -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..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',
@@ -33,17 +33,18 @@ describe('opaque keys (GQ params, rows, columns)', () => {
rows: [],
},
});
- const og = new Omnigraph({ baseUrl: 'http://x', fetch });
- await og.read({
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ 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,
@@ -53,17 +54,18 @@ describe('opaque keys (GQ params, rows, columns)', () => {
query_name: 'm',
},
});
- const og = new Omnigraph({ baseUrl: 'http://x', fetch });
- await og.change({
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ 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',
@@ -76,8 +78,8 @@ describe('opaque keys (GQ params, rows, columns)', () => {
],
},
});
- const og = new Omnigraph({ baseUrl: 'http://x', fetch });
- const r = await og.read({ branch: 'main', querySource: 'query q() { ... }' });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ 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/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..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';
@@ -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..81fddb4 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,41 +139,34 @@ 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 /query, /mutate, /load, /snapshot, /export under /graphs/{graphId}', async () => {
+ const loadBody = {
+ 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: 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.ingest({ branch: 'main', mode: 'merge', data: '{}\n' });
+ await og.load({ 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/ingest');
- expect(calls[5]?.url).toBe('http://x/graphs/alpha/snapshot');
- expect(calls[6]?.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 () => {
@@ -197,25 +190,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..a791201 100644
--- a/scripts/check-coverage.ts
+++ b/scripts/check-coverage.ts
@@ -150,11 +150,35 @@ 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;
+}
+
+// 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) {
- expectedByKey.set(`${e.method} ${normalizePath(e.path)}`, e);
+ expectedByKey.set(`${e.method} ${normalizePath(flattenSpecPath(e.path))}`, e);
}
const seenInSdk = new Set();
@@ -181,10 +205,25 @@ for (const c of calls) {
}
for (const e of expected) {
- const key = `${e.method} ${normalizePath(e.path)}`;
- if (!seenInSdk.has(key)) {
- errors.push(`No SDK binding for spec operation ${e.method} ${e.path}`);
+ const key = `${e.method} ${normalizePath(flattenSpecPath(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) {
@@ -197,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.`,
);
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."
},