diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 20ad027..a570045 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,7 +18,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- name: Verify package versions match pinned server
run: pnpm run check-versions
- - name: Verify spec matches upstream pinned tag
+ - name: Verify spec matches upstream pinned source
run: pnpm run check-drift
- name: Verify generated SDK is up to date
run: |
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 4ae5bf7..33c42fe 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -8,6 +8,7 @@ on:
jobs:
e2e:
runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -18,14 +19,17 @@ jobs:
- run: pnpm install --frozen-lockfile
- - name: Read pinned server version
+ - name: Resolve pinned server source
id: ver
run: |
- v=$(node -p "require('./package.json').omnigraph.serverVersion")
- echo "version=$v" >> "$GITHUB_OUTPUT"
- echo "Pinned omnigraph-server: v$v"
+ pnpm exec tsx -e '
+ import { readServerPin } from "./scripts/server-pin.ts";
+ const pin = readServerPin();
+ console.log("version=" + pin.version + "\nref=" + pin.ref + "\nsource_pinned=" + pin.sourcePinned);
+ ' >> "$GITHUB_OUTPUT"
- name: Download omnigraph-server binary
+ if: steps.ver.outputs.source_pinned == 'false'
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
@@ -42,6 +46,44 @@ jobs:
chmod +x "$HOME/.local/bin/omnigraph" "$HOME/.local/bin/omnigraph-server"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
+ # Until the server release exists, run the same e2e suite against the
+ # exact source used by sync-spec and the MCP references, never main.
+ - name: Check out immutable server candidate
+ if: steps.ver.outputs.source_pinned == 'true'
+ uses: actions/checkout@v4
+ with:
+ repository: ModernRelay/omnigraph
+ ref: ${{ steps.ver.outputs.ref }}
+ path: .server-source
+ persist-credentials: false
+
+ - name: Prepare server source build
+ if: steps.ver.outputs.source_pinned == 'true'
+ working-directory: .server-source
+ env:
+ SERVER_REF: ${{ steps.ver.outputs.ref }}
+ run: |
+ set -euo pipefail
+ test "$(git rev-parse HEAD)" = "$SERVER_REF"
+ sudo apt-get update
+ sudo apt-get install -y protobuf-compiler libprotobuf-dev
+ rustup toolchain install
+
+ - name: Cache server source build
+ if: steps.ver.outputs.source_pinned == 'true'
+ uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+ with:
+ workspaces: .server-source -> target
+
+ - name: Build pinned server candidate
+ if: steps.ver.outputs.source_pinned == 'true'
+ working-directory: .server-source
+ run: |
+ set -euo pipefail
+ cargo build --locked -p omnigraph-cli --bin omnigraph \
+ -p omnigraph-server --bin omnigraph-server
+ echo "$PWD/target/debug" >> "$GITHUB_PATH"
+
- name: Verify server binary version matches pin
run: |
set -euo pipefail
@@ -64,6 +106,8 @@ jobs:
dir=/tmp/cluster
mkdir -p "$dir"
cp packages/sdk/test/fixtures/schema.pg "$dir/graph.pg"
+ cp packages/sdk/test/fixtures/queries.gq "$dir/queries.gq"
+ omnigraph lint --schema "$dir/graph.pg" --query "$dir/queries.gq"
cat > "$dir/server.policy.yaml" <<'YAML'
version: 1
@@ -97,8 +141,10 @@ jobs:
graphs:
alpha:
schema: ./graph.pg
+ queries: [./queries.gq]
beta:
schema: ./graph.pg
+ queries: [./queries.gq]
policies:
server:
file: ./server.policy.yaml
@@ -109,6 +155,7 @@ jobs:
YAML
omnigraph cluster import --config "$dir"
+ omnigraph cluster plan --config "$dir"
omnigraph cluster apply --config "$dir"
- name: Seed fixture data into each graph
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 921ef98..4b1330b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -45,8 +45,9 @@ jobs:
# Gates — same bar as ci.yml, run again on the exact tag SHA so a stale
# CI run cannot bless a release.
- - run: pnpm run check-versions
- - run: pnpm run check-drift
+ # Reject source-pinned candidates, including manual package publishes
+ # (the same gate is also each package's prepublishOnly hook).
+ - run: pnpm run check-release
- run: pnpm run check-coverage
- run: pnpm run build
- run: pnpm run typecheck
diff --git a/README.md b/README.md
index 12d60c0..0368fe7 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ TypeScript packages for the [Omnigraph](https://github.com/ModernRelay/omnigraph
```
.
-├── spec/openapi.json # committed copy of upstream OpenAPI at the pinned tag
+├── spec/openapi.json # committed copy of upstream OpenAPI at the pinned source
├── scripts/ # spec sync, drift check, version-stamp generator
├── packages/
│ ├── sdk/ # @modernrelay/omnigraph
@@ -23,9 +23,11 @@ TypeScript packages for the [Omnigraph](https://github.com/ModernRelay/omnigraph
## Server-version pin
-The SDK is built against a specific `omnigraph-server` release. The pin lives in **`package.json#omnigraph.serverVersion`** at the repo root and is the single source of truth — `scripts/sync-spec.ts` reads it to fetch the matching OpenAPI spec, and `scripts/gen-version.ts` writes it into `packages/sdk/src/version.gen.ts` so consumers can `import { SERVER_VERSION } from '@modernrelay/omnigraph'`.
+The SDK targets the `omnigraph-server` version in **`package.json#omnigraph.serverVersion`**. By default, the source is the matching `vX.Y.Z` tag. Before a server release exists, **`omnigraph.serverRef`** may temporarily pin a full immutable commit SHA. The OpenAPI spec, MCP reference documents, and live CI server all use that same source. Branch names and abbreviated SHAs are rejected.
-CI enforces drift in two ways: a structural check that the bundled spec matches the upstream tag exactly, and an end-to-end suite that runs against a live `omnigraph-server` of the pinned release.
+The **v0.10** line targets `omnigraph-server` **v0.10.0**. Upgrade the CLI, server, and client integrations together; see the [v0.9 migration notes](packages/sdk/README.md#migrating-from-v09). Development source pins remain supported, but block publishing through both the release workflow and each package's `prepublishOnly` hook.
+
+`scripts/gen-version.ts` stamps the target version as `SERVER_VERSION`. CI checks that the bundled spec matches the pinned source byte for byte and runs live e2e tests against it: a checksum-verified release binary for tags, or a source build for commit pins.
### Versioning policy
@@ -35,16 +37,17 @@ In practice: server cuts `0.4.2`, SDK ships `0.4.0` (and `0.4.1`, `0.4.2`, … a
## Workflow when omnigraph cuts a new release
-1. Bump `package.json#omnigraph.serverVersion` to the new tag (e.g., `0.4.0`).
+1. Bump `package.json#omnigraph.serverVersion` to the new tag (e.g., `0.10.0`). If upgrading from a source-pinned candidate, remove `omnigraph.serverRef` after the release tag exists.
2. `pnpm run sync-spec` — fetches the matching `openapi.json` into `spec/`.
3. `pnpm run generate` — regenerates `packages/sdk/src/generated/` and `packages/sdk/src/version.gen.ts`.
4. Commit `spec/openapi.json`, `packages/sdk/src/generated/`, `packages/sdk/src/version.gen.ts`, and the bumped `package.json`. The PR shows the full upstream change.
5. Bump `packages/sdk/package.json#version` (and `packages/mcp/package.json#version`) to match.
-6. Tag `vX.Y.Z`. `release.yml` publishes both packages to npm (see [Releasing](#releasing)).
+6. Run the full checks, including `pnpm run check-release`, against the released server; the final tag may differ from an earlier source-pinned candidate.
+7. Tag `vX.Y.Z`. `release.yml` publishes both packages to npm (see [Releasing](#releasing)).
## Releasing
-Pushing a `v*` tag triggers `.github/workflows/release.yml`, which runs the full gate (drift, coverage, build, typecheck, test) on the tag SHA and then publishes both `@modernrelay/omnigraph` and `@modernrelay/omnigraph-mcp` with npm provenance. The dist-tag is derived from the tag name: `v1.2.3-alpha.1`, `-beta.x`, `-rc.x` ship under `next`; everything else under `latest`.
+Pushing a `v*` tag triggers `.github/workflows/release.yml`, which runs the full gate (release pin, drift, coverage, build, typecheck, test) on the tag SHA and then publishes both `@modernrelay/omnigraph` and `@modernrelay/omnigraph-mcp` with npm provenance. `check-release` rejects a source pin and verifies the spec against the released server tag. The dist-tag is derived from the tag name: `v1.2.3-alpha.1`, `-beta.x`, `-rc.x` ship under `next`; everything else under `latest`.
```sh
pnpm --filter @modernrelay/omnigraph version 0.4.0-alpha.1
@@ -61,12 +64,13 @@ One-time setup: add an npm `NPM_TOKEN` repo secret (use an Automation token to b
```sh
pnpm install
-pnpm run check-drift # asserts spec matches the pinned server tag
+pnpm run check-drift # asserts spec matches the pinned tag or immutable commit
pnpm run generate # regenerates types + version stamp
pnpm run check-coverage # asserts every spec op has an SDK binding
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
+pnpm run test # pin-tooling tests + mocked unit tests across all packages
+pnpm run check-release # release-only gate; intentionally fails with serverRef set
# 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
@@ -75,7 +79,7 @@ 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.
+CI runs the same sequence (see `.github/workflows/ci.yml` and `e2e.yml`). Its e2e job downloads the pinned release binary or builds the immutable source candidate, then runs the same cluster bootstrap and tests in either mode.
## License
diff --git a/package.json b/package.json
index a7cba77..8e7cb84 100644
--- a/package.json
+++ b/package.json
@@ -11,17 +11,19 @@
},
"packageManager": "pnpm@9.15.0",
"omnigraph": {
- "serverVersion": "0.9.0"
+ "serverVersion": "0.10.0"
},
"scripts": {
"sync-spec": "tsx scripts/sync-spec.ts",
"check-drift": "tsx scripts/check-drift.ts",
"check-versions": "tsx scripts/check-versions.ts",
+ "check-release": "tsx scripts/check-versions.ts --release && tsx scripts/check-drift.ts",
"gen-version": "tsx scripts/gen-version.ts",
"generate": "tsx scripts/gen-version.ts && pnpm --filter @modernrelay/omnigraph run generate",
"build": "pnpm -r run build",
"typecheck": "pnpm -r run typecheck",
- "test": "pnpm -r run test",
+ "test": "pnpm run test:tooling && pnpm -r run test",
+ "test:tooling": "tsx --test scripts/*.test.ts",
"check-coverage": "tsx scripts/check-coverage.ts"
},
"devDependencies": {
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index b56cb36..105b67d 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -55,31 +55,72 @@ Read-only (`readOnlyHint: true`):
| Tool | Purpose |
|---|---|
| `health` | Server liveness + version |
-| `snapshot` | Snapshot of a branch (table list + row counts) |
-| `query` | Run a `.gq` read query |
+| `snapshot` | Snapshot of a branch (`datasets`, type names, entity counts, graph manifest version) |
+| `query` | Run a `.gq` read query; returns the exact read's `graphCommitId` |
| `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 |
+| `commits_changes` | One bounded page of a commit's entity changes relative to its first parent |
+| `changes_poll` | One bounded page of the branch's at-least-once change feed |
| `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 |
-| `load` | Bulk-load NDJSON (`mode: 'merge'` for idempotency). Without `from`, a missing branch is a 404. |
+| `mutate` | Run a `.gq` mutation; optional `ifGraphCommit` selects the conditional-write route |
+| `load` | Bulk-load NDJSON (`mode: 'merge'` for upserts). 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` |
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`.
+### v0.10 writes, errors, and change pages
+
+Successful `mutate` and `load` results include the exact `commit` receipt from
+publication. `mutate` returning `commit: null` means a successful no-op. Comparing
+branch heads before and after is not a receipt: another writer may advance the
+head, and a timed-out operation may still be running.
+
+For read-modify-write, pass `query`'s `graphCommitId` as `mutate`'s
+`ifGraphCommit` argument. The SDK uses `/mutate/if-graph-commit`; it never silently
+falls back to an unconditional write. HTTP 412 with `body.preconditionFailure`
+means no effects: re-read and reconsider the mutation.
+
+SDK request failures set `isError: true` and return JSON with `error`, `status`, `code`,
+`requestId` when available, and the structured server `body`. Request headers and
+request/response objects are not included. No request is retried automatically.
+A 409 is not one universal retry condition: `fullTextIndexRebuildRequired` needs
+an operator's branch-scoped `rebuild-full-text-indexes`, `keyConflict` needs an
+identity/operation decision, and merge conflicts need reconciliation.
+`recoveryRequired` needs operator recovery. A lost response leaves the outcome
+unknown; inspect intended content and relevant history before deciding to replay.
+
+`commits_changes` accepts `commitId`, optional `pageToken`, `limit`, and array
+filters `kind`, `type`, and `op`. `changes_poll` accepts the same filters plus
+`branch` and exactly one of `start`, `cursor`, or `pageToken` (or none, meaning
+`start: "now"`). Follow `nextPageToken` with unchanged branch/filters. Only a
+terminal feed `cursor` is durable; apply complete commit blocks idempotently by
+`graphCommitId` and persist that cursor with the applied data. A 410
+`changeFeedGap` requires the SDK's streamed baseline/reset workflow. Full baseline
+exports and binary Blob payloads are deliberately not buffered into MCP results.
+
### Resources
- `omnigraph://schema` — text/plain `.pg` source
- `omnigraph://branches` — application/json branch name list
- `omnigraph://graphs` — application/json `[{ graphId, uri }]` (requires a `graph_list` policy grant; the management surface is closed by default)
+- `omnigraph://best-practices/index` — index of the bundled query, data, schema, and search references
+
+Best-practice references are fetched from the same pinned upstream contract as
+the SDK, not moving `main`. Operator/CLI examples are not additional MCP tools;
+available types, properties, and vector dimensions come from the live schema.
+The older `remote-ops` reference is intentionally excluded until its blanket
+retry and branch-head verification advice is refreshed for v0.10. The write/error
+rules above and the server's initialization instructions are authoritative for
+this MCP version.
## License
diff --git a/packages/mcp/cookbook-descriptions.json b/packages/mcp/cookbook-descriptions.json
index 7360584..28fda49 100644
--- a/packages/mcp/cookbook-descriptions.json
+++ b/packages/mcp/cookbook-descriptions.json
@@ -10,18 +10,15 @@
},
"schema": {
"title": "Schema authoring and evolution",
- "description": "Read before calling `schema_apply`. Covers .pg grammar, decorators (@key, @unique, @embed, @rename_from), inline-only enums, the add-optional→backfill→tighten sequence for non-nullable additions, and why apply is main-only and rejects open feature branches."
- },
- "remote-ops": {
- "title": "Remote-operation safety",
- "description": "Read after any 504 or unexpected error. Covers the verify-after-write ritual (commits_list head before/after), retry-safety table by node kind (pointer types dedupe via @key; append-only types duplicate on retry), and the `sync_branch()` server-internal error vs. a tool."
+ "description": "Read to interpret `schema_get` and .pg declarations. Covers decorators, inline enums, stable renames, and add-optional/backfill evolution; tightening optional properties remains unsupported. Schema changes are operator-owned through cluster apply, not an MCP tool."
},
"search": {
"title": "Vector and full-text search",
- "description": "Read before using nearest/bm25/rrf. Covers the scope-first-rank-second pattern, the hardcoded embedding model (gemini-embedding-2-preview, Vector(3072)), and trailing-`limit` requirement."
+ "description": "Read before using nearest/bm25/rrf. Covers scope-first-rank-second queries, schema-declared embeddings, and the trailing-`limit` requirement. Match vector dimensions and models to the live deployment; CLI examples are not MCP tools."
}
},
"skipped": {
+ "remote-ops": "Awaiting upstream v0.10 refresh: its blanket 409 retry and branch-head verification advice conflicts with exact commit receipts and typed refusals. Current MCP instructions and README define write/error handling instead.",
"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.",
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 8df04dd..8b71aae 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@modernrelay/omnigraph-mcp",
- "version": "0.9.0",
+ "version": "0.10.0",
"description": "MCP server exposing an Omnigraph database to LLM clients (Tools + Resources, stdio transport).",
"license": "MIT",
"repository": {
@@ -39,7 +39,7 @@
"pretest": "pnpm run sync-cookbook",
"test": "vitest run",
"clean": "rm -rf dist src/best-practices.gen.ts",
- "prepublishOnly": "pnpm run build"
+ "prepublishOnly": "pnpm --dir ../.. run check-release && pnpm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
diff --git a/packages/mcp/scripts/sync-cookbook.ts b/packages/mcp/scripts/sync-cookbook.ts
index 7432b50..0e00f8d 100644
--- a/packages/mcp/scripts/sync-cookbook.ts
+++ b/packages/mcp/scripts/sync-cookbook.ts
@@ -17,7 +17,8 @@
// the cue it needs.
//
// Source of truth: the `omnigraph` skill references in ModernRelay/omnigraph @
-// main (moved there from the retired ModernRelay/omnigraph-cookbooks repo). The
+// the same server pin as the OpenAPI spec (release tag or immutable candidate).
+// 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.
@@ -25,6 +26,7 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { readServerPin } from '../../../scripts/server-pin.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const PKG_ROOT = dirname(HERE);
@@ -32,7 +34,7 @@ const OUT = join(PKG_ROOT, 'src/best-practices.gen.ts');
const DESCRIPTIONS_PATH = join(PKG_ROOT, 'cookbook-descriptions.json');
const REPO = 'ModernRelay/omnigraph';
-const REF = 'main';
+const REF = readServerPin().ref;
const REF_DIR = 'skills/omnigraph/references';
const LIST_URL = `https://api.github.com/repos/${REPO}/contents/${REF_DIR}?ref=${REF}`;
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index bd315bb..7c3a935 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -15,6 +15,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import {
Omnigraph,
+ OmnigraphError,
type FetchLike,
SERVER_VERSION as SDK_SERVER_VERSION,
} from '@modernrelay/omnigraph';
@@ -30,23 +31,26 @@ After schema, consult the matching best-practices resource for the task at hand:
- omnigraph://best-practices/queries — before .gq queries (query/mutate)
- omnigraph://best-practices/data — before load (mode selection, branch loop)
- omnigraph://best-practices/schema — 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
+These references also contain operator/CLI examples, not additional MCP tools. The live schema determines available types, properties, and vector dimensions; example models and node names are not deployment guarantees. The write and error rules below are the v0.10 MCP contract.
+
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 \`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. \`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.
+4. \`load mode: "merge"\` upserts stable keys; it is not request deduplication. Reconcile an ambiguous outcome before replaying. \`"overwrite"\` replaces supplied types. \`"append"\` fails on key collision.
+5. Successful mutations and loads return an exact \`commit\` receipt. A mutation with \`commit: null\` is a successful no-op, not a failed write. A separate branch-head read cannot prove which writer committed. A timeout or lost response leaves the outcome unknown: verify the intended content and relevant commit history before considering a replay; never infer retry safety from an unchanged head or node type name.
+6. For read-modify-write, use \`query.graphCommitId\` as \`mutate.ifGraphCommit\`. It selects the dedicated conditional-write route; HTTP 412 with \`preconditionFailure\` means no effects. Re-read and reconsider the change instead of blindly replaying it. Never fall back to an unconditional mutation when the conditional route is unavailable.
7. Risky/large writes: \`branches_create\` from main → \`load\` onto the branch → verify → \`branches_merge\` → \`branches_delete\`.
8. Schema is read-only over this MCP. \`schema_get\` returns the active .pg source; there is no \`schema_apply\` tool. A cluster-managed graph rejects HTTP schema apply (409) — schema changes go through \`omnigraph cluster apply\` (an operator/CLI action), not an agent tool.
Date format: ISO strings on \`mutate\` params; integer days-since-epoch in load JSONL \`Date\` fields. \`DateTime\` is ISO on both.
-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.
+Errors carry \`status\`, \`code\`, and structured \`body\` details. Do not retry every 409: \`fullTextIndexRebuildRequired\` needs an operator's branch-scoped \`rebuild-full-text-indexes\` action, not another search; \`keyConflict\` needs an identity/operation decision; merge conflicts need reconciliation. \`recoveryRequired\` needs operator recovery before retry. \`sync_branch()\`, if mentioned, is server-internal text, not an MCP tool. This MCP never retries requests automatically.
+
+\`commits_changes\` and \`changes_poll\` return one bounded page. Continue with \`nextPageToken\`, keeping branch and filters unchanged; a page token is not a durable cursor. Feed delivery is at-least-once: apply completed commit blocks idempotently by graphCommitId and persist the terminal cursor with the applied data. A 410 \`changeFeedGap\` requires a streamed baseline/reset through the SDK or operator workflow; this MCP does not buffer full baselines.
Depth: https://github.com/ModernRelay/omnigraph/tree/main/skills/omnigraph`;
@@ -76,6 +80,39 @@ function plainText(text: string) {
return [{ type: 'text' as const, text }];
}
+// Keep machine-readable server refusal details available to agents. The SDK's
+// request/response objects can contain credentials and are never serialized.
+async function toolResult(run: () => Promise, render: (value: T) => ReturnType = jsonText) {
+ try {
+ return { content: render(await run()) };
+ } catch (error) {
+ if (!(error instanceof OmnigraphError)) throw error;
+ return {
+ isError: true,
+ content: jsonText({
+ error: error.message,
+ status: error.status,
+ code: error.code,
+ requestId: error.requestId,
+ body: error.body,
+ }),
+ };
+ }
+}
+
+const ChangeFilters = {
+ kind: z.array(z.enum(['node', 'edge'])).optional(),
+ type: z.array(z.string().min(1)).optional(),
+ op: z.array(z.enum(['insert', 'update', 'delete'])).optional(),
+ pageToken: z.string().min(1).optional(),
+ limit: z.number().int().positive().optional(),
+};
+const FeedStart = z.union([
+ z.literal('now'),
+ z.literal('beginning'),
+ z.string().startsWith('after:').min(7).transform((value) => value as `after:${string}`),
+]);
+
export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
const og = new Omnigraph({
baseUrl: opts.baseUrl,
@@ -107,10 +144,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async () => {
- const h = await og.health();
- return { content: jsonText({ ...h, sdkServerVersion: SDK_SERVER_VERSION }) };
- },
+ async () => toolResult(async () => ({ ...(await og.health()), sdkServerVersion: SDK_SERVER_VERSION })),
);
server.registerTool(
@@ -118,15 +152,12 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
{
title: 'Branch snapshot',
description:
- 'Return the current snapshot of a branch — every node/edge table with its row count. ' +
+ 'Return the current snapshot of a branch — node/edge datasets with type names and entity counts. ' +
'Useful for an agent to assess graph size before authoring a query.',
inputSchema: { branch: z.string().optional() },
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async ({ branch }) => {
- const s = await og.snapshot({ branch: branch ?? defaultBranch });
- return { content: jsonText(s) };
- },
+ async ({ branch }) => toolResult(() => og.snapshot({ branch: branch ?? defaultBranch })),
);
server.registerTool(
@@ -138,7 +169,8 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
'Canonical read endpoint as of server 0.6.0 (successor to `read`). ' +
'`query` is the full query text. `params` is a free-form map matched ' +
'by name to `$varName` placeholders in the query. Returns rows + columns; ' +
- 'row keys are caller-defined and not transformed.',
+ 'row keys are caller-defined and not transformed. graphCommitId identifies the exact read ' +
+ 'snapshot and can be passed to mutate as ifGraphCommit.',
inputSchema: {
query: z.string().min(1),
name: z.string().optional(),
@@ -152,14 +184,13 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
// `branch` and `snapshot` are mutually exclusive per the spec. Only
// apply the defaultBranch fallback when the caller has not pinned a
// snapshot — otherwise we'd send both and the server would reject.
- const r = await og.query({
+ return toolResult(() => og.query({
query,
name,
params,
branch: snapshot ? branch : (branch ?? defaultBranch),
snapshot,
- });
- return { content: jsonText(r) };
+ }));
},
);
@@ -173,10 +204,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async () => {
- const s = await og.schema.get();
- return { content: plainText(s.schemaSource) };
- },
+ async () => toolResult(() => og.schema.get(), (s) => plainText(s.schemaSource)),
);
server.registerTool(
@@ -187,10 +215,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async () => {
- const list = await og.branches.list();
- return { content: jsonText({ branches: list }) };
- },
+ async () => toolResult(async () => ({ branches: await og.branches.list() })),
);
server.registerTool(
@@ -205,10 +230,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: {},
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async () => {
- const graphs = await og.graphs.list();
- return { content: jsonText({ graphs }) };
- },
+ async () => toolResult(async () => ({ graphs: await og.graphs.list() })),
);
server.registerTool(
@@ -219,10 +241,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: { branch: z.string().optional() },
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async ({ branch }) => {
- const commits = await og.commits.list({ branch: branch ?? defaultBranch });
- return { content: jsonText({ commits }) };
- },
+ async ({ branch }) => toolResult(async () => ({ commits: await og.commits.list({ branch: branch ?? defaultBranch }) })),
);
server.registerTool(
@@ -233,10 +252,40 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: { commitId: z.string().min(1) },
annotations: { readOnlyHint: true, openWorldHint: false },
},
- async ({ commitId }) => {
- const commit = await og.commits.retrieve(commitId);
- return { content: jsonText(commit) };
+ async ({ commitId }) => toolResult(() => og.commits.retrieve(commitId)),
+ );
+
+ server.registerTool(
+ 'commits_changes',
+ {
+ title: 'Inspect commit entity changes',
+ description:
+ 'Read one bounded page of exact before/after entity changes relative to a commit\'s first parent. ' +
+ 'Continue with nextPageToken as pageToken, preserving filters; it is not a feed cursor.',
+ inputSchema: { commitId: z.string().min(1), ...ChangeFilters },
+ annotations: { readOnlyHint: true, openWorldHint: false },
+ },
+ async ({ commitId, ...input }) => toolResult(() => og.commits.changes(commitId, input)),
+ );
+
+ server.registerTool(
+ 'changes_poll',
+ {
+ title: 'Poll entity change feed',
+ description:
+ 'Read one bounded page from a branch\'s at-least-once change feed. ' +
+ 'cursor, start, and pageToken are mutually exclusive; omitted start means now. ' +
+ 'Keep filters unchanged across pages. Only a terminal cursor is durable; persist it with applied ' +
+ 'complete commit blocks. A 410 gap requires a streamed SDK/operator baseline reset.',
+ inputSchema: {
+ branch: z.string().optional(),
+ cursor: z.string().min(1).optional(),
+ start: FeedStart.optional(),
+ ...ChangeFilters,
+ },
+ annotations: { readOnlyHint: true, openWorldHint: false },
},
+ async ({ branch, ...input }) => toolResult(() => og.changes.poll({ ...input, branch: branch ?? defaultBranch })),
);
// ---------- Tools: mutating --------------------------------------------
@@ -248,24 +297,24 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
description:
'Run a .gq mutation (insert/update/delete) against a branch. Canonical write ' +
'endpoint as of server 0.6.0 (successor to `change`). Multi-statement mutations ' +
- 'are atomic at the commit boundary. Returns affectedNodes / affectedEdges counts.',
+ 'are atomic at the commit boundary. Returns affectedNodes / affectedEdges counts and an exact ' +
+ 'commit receipt (null for a successful no-op). ifGraphCommit requires the branch head from a prior ' +
+ 'query and uses the dedicated conditional route; stale heads fail with 412 before effects.',
inputSchema: {
query: z.string().min(1),
name: z.string().optional(),
params: z.record(z.unknown()).optional(),
branch: z.string().optional(),
+ ifGraphCommit: z.string().min(1).optional(),
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
},
- async ({ query, name, params, branch }) => {
- const r = await og.mutate({
+ async ({ query, name, params, branch, ifGraphCommit }) => toolResult(() => og.mutate({
query,
name,
params,
branch: branch ?? defaultBranch,
- });
- return { content: jsonText(r) };
- },
+ }, { ifGraphCommit })),
);
server.registerTool(
@@ -273,9 +322,10 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
{
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. ' +
- 'Without `from`, the target branch must already exist (a missing branch is a 404); pass `from` to fork-if-missing.',
+ 'Bulk-load NDJSON data into a branch. `mode: "merge"` upserts stable keys, not request deduplication. Reconcile ambiguous outcomes before replay. ' +
+ '`mode: "append"` is strict insert (errors on duplicate). `mode: "overwrite"` replaces supplied types. ' +
+ 'Without `from`, the target branch must already exist (a missing branch is a 404); pass `from` to fork-if-missing. ' +
+ 'Returns an exact commit receipt. Oversized batches fail with 413; split them into separate commits.',
inputSchema: {
branch: z.string().min(1),
from: z.string().optional(),
@@ -284,10 +334,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.load({ branch, from, mode, data });
- return { content: jsonText(r) };
- },
+ async ({ branch, from, mode, data }) => toolResult(() => og.load({ branch, from, mode, data })),
);
server.registerTool(
@@ -298,10 +345,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: { name: z.string().min(1), from: z.string().optional() },
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
},
- async ({ name, from }) => {
- const r = await og.branches.create({ name, from: from ?? defaultBranch });
- return { content: jsonText(r) };
- },
+ async ({ name, from }) => toolResult(() => og.branches.create({ name, from: from ?? defaultBranch })),
);
server.registerTool(
@@ -312,10 +356,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
inputSchema: { name: z.string().min(1) },
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
},
- async ({ name }) => {
- const r = await og.branches.delete(name);
- return { content: jsonText(r) };
- },
+ async ({ name }) => toolResult(() => og.branches.delete(name)),
);
server.registerTool(
@@ -330,10 +371,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer {
},
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
},
- async ({ source, target }) => {
- const r = await og.branches.merge({ source, target: target ?? defaultBranch });
- return { content: jsonText(r) };
- },
+ async ({ source, target }) => toolResult(() => og.branches.merge({ source, target: target ?? defaultBranch })),
);
// NOTE: no `schema_apply` tool. omnigraph-server 0.7.0 is cluster-only, and a
diff --git a/packages/mcp/src/version.gen.ts b/packages/mcp/src/version.gen.ts
index 1a2afe7..33c3061 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.9.0";
+export const MCP_PACKAGE_VERSION = "0.10.0";
diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts
index e10dce6..54405ce 100644
--- a/packages/mcp/test/server.test.ts
+++ b/packages/mcp/test/server.test.ts
@@ -1,7 +1,31 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { describe, expect, it } from 'vitest';
-import { createOmnigraphMcpServer } from '../src/server';
+import { SERVER_VERSION } from '@modernrelay/omnigraph';
+import { createOmnigraphMcpServer, type CreateServerOptions } from '../src/server';
+import { MCP_PACKAGE_VERSION } from '../src/version.gen';
+
+const COMMIT = {
+ graph_commit_id: '01KQ',
+ graph_branch: 'main',
+ graph_manifest_version: 2,
+ parent_commit_id: '01KP',
+ merged_parent_commit_id: null,
+ actor_id: null,
+ created_at: 1714000000000000,
+};
+const CHANGE_BLOCK = {
+ cause: { graph_commit_id: '01KQ', authored_branch: 'main', authored_at: 1714000000000000 },
+ changes: [{
+ kind: 'node', type: { id: 'person-life', name: 'Person' }, id: 'alice', op: 'update',
+ before: { properties: { display_name: 'Alice', nested_data: { UserKey: 1 } } },
+ after: { properties: { display_name: 'Alice Updated', nested_data: { UserKey: 2 } } },
+ }],
+};
+
+function toolJson(result: Record) {
+ return JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text);
+}
// Recover the flat operation path from a (possibly graph-scoped) URL. The
// server is configured with a graphId, so the transport sends graph-scoped
@@ -26,13 +50,15 @@ function fakeFetch(): typeof globalThis.fetch {
});
if (method === 'GET' && path === '/healthz') {
- return respond(200, { status: 'ok', version: '0.3.0' });
+ return respond(200, { status: 'ok', version: '0.10.0' });
}
if (method === 'GET' && path === '/snapshot') {
return respond(200, {
- branch: 'main',
- snapshot_id: 'snap-1',
- tables: [{ table_key: 'node:Person', row_count: 4, table_version: 1, table_branch: null }],
+ graph_branch: 'main',
+ graph_manifest_version: 2,
+ internal_schema_version: 6,
+ datasets: [{ entity_kind: 'node', type_name: 'Person', entity_count: 4,
+ dataset_path: 'nodes/Person', published_dataset_version: 1, native_dataset_branch: null }],
});
}
if (method === 'GET' && path === '/branches') {
@@ -41,45 +67,43 @@ function fakeFetch(): typeof globalThis.fetch {
if (method === 'GET' && path === '/schema') {
return respond(200, { schema_source: 'node Person { name: String @key }' });
}
- if (method === 'POST' && (path === '/read' || path === '/query')) {
+ if (method === 'POST' && path === '/query') {
return respond(200, {
query_name: 'q',
target: { branch: 'main', snapshot: null },
row_count: 1,
columns: ['$p.name'],
rows: [{ '$p.name': 'Alice' }],
+ graph_commit_id: '01KP',
});
}
- if (method === 'POST' && (path === '/change' || path === '/mutate')) {
+ if (method === 'POST' && (path === '/mutate' || path === '/mutate/if-graph-commit')) {
return respond(200, {
actor_id: null,
affected_edges: 0,
affected_nodes: 1,
branch: 'main',
query_name: 'q',
+ commit: COMMIT,
+ });
+ }
+ if (method === 'POST' && path === '/load') {
+ return respond(200, {
+ uri: 'file:///graph', branch: 'main', branch_created: false, mode: 'merge',
+ nodes: [{ name: 'Person', entities_loaded: 1 }], edges: [], total_entities: 1, commit: COMMIT,
});
}
if (method === 'GET' && path === '/commits') {
return respond(200, {
- commits: [
- {
- graph_commit_id: '01KQ',
- manifest_branch: null,
- manifest_version: 1,
- parent_commit_id: null,
- merged_parent_commit_id: null,
- actor_id: null,
- created_at: 1,
- },
- ],
+ commits: [COMMIT],
});
}
return respond(404, { error: 'not found', code: 'not_found' });
}) as unknown as typeof globalThis.fetch;
}
-async function setup() {
- const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: fakeFetch() });
+async function setup(opts: Partial = {}) {
+ const server = createOmnigraphMcpServer({ baseUrl: 'http://x', graphId: 'g', fetch: fakeFetch(), ...opts });
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)]);
@@ -91,7 +115,7 @@ describe('omnigraph-mcp server', () => {
const { client } = await setup();
const info = client.getServerVersion();
expect(info?.name).toBe('omnigraph-mcp');
- expect(info?.version).toBe('0.9.0');
+ expect(info?.version).toBe(MCP_PACKAGE_VERSION);
});
it('lists every expected tool', async () => {
@@ -104,6 +128,8 @@ describe('omnigraph-mcp server', () => {
'branches_delete',
'branches_list',
'branches_merge',
+ 'changes_poll',
+ 'commits_changes',
'commits_get',
'commits_list',
'graphs_list',
@@ -126,6 +152,8 @@ describe('omnigraph-mcp server', () => {
expect(byName.get('branches_merge')?.annotations?.destructiveHint).toBe(true);
expect(byName.get('snapshot')?.annotations?.readOnlyHint).toBe(true);
expect(byName.get('schema_get')?.annotations?.readOnlyHint).toBe(true);
+ expect(byName.get('changes_poll')?.annotations?.readOnlyHint).toBe(true);
+ expect(byName.get('commits_changes')?.annotations?.readOnlyHint).toBe(true);
});
it('calls the health tool and round-trips the SDK SERVER_VERSION', async () => {
@@ -134,8 +162,8 @@ describe('omnigraph-mcp server', () => {
const block = (r.content as Array<{ type: string; text: string }>)[0]!;
const parsed = JSON.parse(block.text);
expect(parsed.status).toBe('ok');
- expect(parsed.version).toBe('0.3.0');
- expect(parsed.sdkServerVersion).toBe('0.9.0');
+ expect(parsed.version).toBe('0.10.0');
+ expect(parsed.sdkServerVersion).toBe(SERVER_VERSION);
});
it('calls the query tool and preserves opaque param keys', async () => {
@@ -154,6 +182,128 @@ describe('omnigraph-mcp server', () => {
expect(parsed.queryName).toBe('q');
expect(parsed.rowCount).toBe(1);
expect(parsed.rows[0]['$p.name']).toBe('Alice');
+ expect(parsed.graphCommitId).toBe('01KP');
+ });
+
+ it('returns v0.10 snapshot vocabulary and exact load/commit receipts', async () => {
+ const { client } = await setup();
+ const snapshot = toolJson(await client.callTool({ name: 'snapshot', arguments: {} }));
+ expect(snapshot).toEqual({
+ graphBranch: 'main', graphManifestVersion: 2, internalSchemaVersion: 6,
+ datasets: [{ entityKind: 'node', typeName: 'Person', entityCount: 4,
+ datasetPath: 'nodes/Person', publishedDatasetVersion: 1, nativeDatasetBranch: null }],
+ });
+ const load = toolJson(await client.callTool({
+ name: 'load', arguments: { branch: 'main', mode: 'merge', data: '{"type":"Person","data":{"name":"Alice"}}' },
+ }));
+ expect(load.totalEntities).toBe(1);
+ expect(load.nodes).toEqual([{ name: 'Person', entitiesLoaded: 1 }]);
+ expect(load.commit.graphCommitId).toBe('01KQ');
+ const commits = toolJson(await client.callTool({ name: 'commits_list', arguments: {} }));
+ expect(commits.commits[0]).toEqual(load.commit);
+ expect(load.commit.graphManifestVersion).toBe(2);
+ expect(load.commit.createdAt).toBe(1714000000000000);
+ });
+
+ it('routes conditional mutations exclusively through the dedicated endpoint', async () => {
+ const requests: Array<{ path: string; header: string | null; body: unknown }> = [];
+ const fallback = fakeFetch();
+ const { client } = await setup({ fetch: async (input, init) => {
+ requests.push({
+ path: flatPath(String(input)),
+ header: new Headers(init?.headers).get('Omnigraph-If-Graph-Commit'),
+ body: JSON.parse(String(init?.body)),
+ });
+ return fallback(input, init);
+ } });
+ const result = await client.callTool({ name: 'mutate', arguments: {
+ query: 'query q($newName: String) { update Person set { name: $newName } where name = "Alice" }',
+ params: { newName: 'Alice Updated' }, ifGraphCommit: '01KP',
+ } });
+ expect(result.isError).not.toBe(true);
+ expect(requests).toHaveLength(1);
+ expect(requests[0]?.path).toBe('/mutate/if-graph-commit');
+ expect(requests[0]?.header).toBe('01KP');
+ expect(requests[0]?.body).toEqual({
+ query: 'query q($newName: String) { update Person set { name: $newName } where name = "Alice" }',
+ params: { newName: 'Alice Updated' }, branch: 'main',
+ });
+ expect(toolJson(result).commit.graphCommitId).toBe('01KQ');
+ });
+
+ it('returns successful no-op mutations without claiming a commit', async () => {
+ const { client } = await setup({ fetch: async () => new Response(JSON.stringify({
+ branch: 'main', query_name: 'noop', affected_nodes: 0, affected_edges: 0, commit: null,
+ }), { headers: { 'content-type': 'application/json' } }) });
+ const result = await client.callTool({ name: 'mutate', arguments: { query: 'query noop() { delete Person where name = "absent" }' } });
+ expect(result.isError).not.toBe(true);
+ expect(toolJson(result).commit).toBeNull();
+ });
+
+ it.each([
+ { status: 412, code: undefined, detail: { precondition_failure: { expected: '01KP', actual: '01KQ' } },
+ field: 'preconditionFailure', value: { expected: '01KP', actual: '01KQ' }, tool: 'mutate', args: { query: 'query q() { insert Person { name: "Alice" } }', ifGraphCommit: '01KP' } },
+ { status: 409, code: 'conflict', detail: { full_text_index_rebuild_required: { index: 'Person.name', reason: 'missing certificate' } },
+ field: 'fullTextIndexRebuildRequired', value: { index: 'Person.name', reason: 'missing certificate' }, tool: 'query', args: { query: 'query q() { match { $p: Person } return { $p.name } }' } },
+ { status: 410, code: undefined, detail: { change_feed_gap: { cursor: 'old', first_unreadable_commit_id: '01KP' } },
+ field: 'changeFeedGap', value: { cursor: 'old', firstUnreadableCommitId: '01KP' }, tool: 'changes_poll', args: { cursor: 'old' } },
+ { status: 404, code: 'not_found', detail: {}, field: undefined, value: undefined,
+ tool: 'mutate', args: { query: 'query q() { insert Person { name: "Alice" } }', ifGraphCommit: '01KP' } },
+ ])('preserves structured $status refusals without retries or credential objects', async ({ status, code, detail, field, value, tool, args }) => {
+ let calls = 0;
+ const { client } = await setup({ token: 'private-bearer-token', fetch: async (_input, init) => {
+ calls++;
+ expect(new Headers(init?.headers).get('authorization')).toBe('Bearer private-bearer-token');
+ return new Response(JSON.stringify({ error: 'operation refused', code, ...detail }), {
+ status, headers: { 'content-type': 'application/json', 'x-request-id': 'request-1' },
+ });
+ } });
+ const result = await client.callTool({ name: tool, arguments: args });
+ expect(result.isError).toBe(true);
+ expect(calls).toBe(1);
+ const parsed = toolJson(result);
+ expect(parsed).toMatchObject({ status, error: 'operation refused', requestId: 'request-1' });
+ if (field) expect(parsed.body[field]).toEqual(value);
+ expect(parsed).not.toHaveProperty('request');
+ expect(parsed).not.toHaveProperty('response');
+ expect(JSON.stringify(parsed)).not.toContain('private-bearer-token');
+ expect(JSON.stringify(parsed)).not.toContain('http://x');
+ });
+
+ it('reads bounded change pages with repeated filters and opaque user properties', async () => {
+ const urls: URL[] = [];
+ const { client } = await setup({ defaultBranch: 'review', fetch: async (input) => {
+ const url = new URL(String(input));
+ urls.push(url);
+ const body = url.pathname.includes('/commits/')
+ ? { ...CHANGE_BLOCK, next_page_token: 'commit-page' }
+ : url.searchParams.has('page_token')
+ ? { blocks: [], cursor: 'durable-cursor', caught_up: true }
+ : { blocks: [CHANGE_BLOCK], next_page_token: 'feed-page' };
+ return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
+ } });
+ const filters = { kind: ['node', 'edge'], type: ['Person', 'Knows'], op: ['update'], limit: 2 };
+ const diff = toolJson(await client.callTool({ name: 'commits_changes', arguments: { commitId: 'commit/with slash', pageToken: 'first-page', ...filters } }));
+ expect(urls[0]?.pathname).toBe('/graphs/g/commits/commit%2Fwith%20slash/changes');
+ expect(urls[0]?.searchParams.get('page_token')).toBe('first-page');
+ expect(diff.nextPageToken).toBe('commit-page');
+ expect(diff.cause.graphCommitId).toBe('01KQ');
+ expect(diff.changes[0].after.properties).toEqual(CHANGE_BLOCK.changes[0]!.after.properties);
+ const page = toolJson(await client.callTool({ name: 'changes_poll', arguments: { start: 'after:01KP', ...filters } }));
+ expect(page.nextPageToken).toBe('feed-page');
+ expect(page.cursor).toBeUndefined();
+ expect(urls).toHaveLength(2); // No hidden pagination loop.
+ const terminal = toolJson(await client.callTool({ name: 'changes_poll', arguments: { pageToken: page.nextPageToken, ...filters } }));
+ expect(terminal).toEqual({ blocks: [], cursor: 'durable-cursor', caughtUp: true });
+ expect(urls[1]?.searchParams.get('start')).toBe('after:01KP');
+ expect(urls[2]?.searchParams.has('start')).toBe(false);
+ for (const url of urls) {
+ expect(url.searchParams.getAll('kind')).toEqual(filters.kind);
+ expect(url.searchParams.getAll('type')).toEqual(filters.type);
+ expect(url.searchParams.getAll('op')).toEqual(filters.op);
+ }
+ expect(urls[1]?.searchParams.get('branch')).toBe('review');
+ expect(urls[2]?.searchParams.get('branch')).toBe('review');
});
it('mutate tool accepts canonical query/name fields', async () => {
@@ -217,7 +367,6 @@ describe('omnigraph-mcp server', () => {
'omnigraph://best-practices/data',
'omnigraph://best-practices/index',
'omnigraph://best-practices/queries',
- 'omnigraph://best-practices/remote-ops',
'omnigraph://best-practices/schema',
'omnigraph://best-practices/search',
'omnigraph://branches',
@@ -236,7 +385,7 @@ describe('omnigraph-mcp server', () => {
// Index lists every cookbook entry.
const idx = await client.readResource({ uri: 'omnigraph://best-practices/index' });
const idxText = (idx.contents as Array<{ uri: string; text?: string }>)[0]!.text!;
- for (const key of ['queries', 'data', 'schema', 'remote-ops', 'search']) {
+ for (const key of ['queries', 'data', 'schema', 'search']) {
expect(idxText).toContain(`omnigraph://best-practices/${key}`);
}
});
@@ -248,6 +397,14 @@ describe('omnigraph-mcp server', () => {
// Sentinel phrases the LLM-facing brief must keep.
expect(instructions).toMatch(/ALWAYS read .*schema.* FIRST/);
expect(instructions).toMatch(/best-practices/);
+ expect(instructions).toContain('commit: null');
+ expect(instructions).toContain('ifGraphCommit');
+ expect(instructions).toContain('Do not retry every 409');
+ expect(instructions).toContain('fullTextIndexRebuildRequired');
+ expect(instructions).not.toContain('Retry once');
+ expect(instructions).not.toContain('best-practices/remote-ops');
+ expect(instructions).toContain('it is not request deduplication');
+ expect(instructions).not.toContain('idempotent — use this');
});
it('branches_create honours configured defaultBranch when `from` is omitted', async () => {
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index d3d7f19..b166de8 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -11,7 +11,10 @@ npm install @modernrelay/omnigraph
# or: pnpm add @modernrelay/omnigraph
```
-Requires **Node 22+** (uses native `fetch` and web streams). Works in Bun and Deno; browser compatibility depends on whether your `omnigraph-server` is reachable from the browser context (CORS).
+Requires **Node 22+** (uses native `fetch` and web streams). Browser support depends on server CORS; browsers also hide manual cross-origin redirects, so inspecting external Blob descriptors requires a server-side runtime.
+
+**v0.10 targets omnigraph-server v0.10.0.** Upgrade the CLI, server, and client
+integrations together; see [Migrating from v0.9](#migrating-from-v09).
## First call
@@ -38,7 +41,7 @@ That's the whole pattern: instantiate once (with a `graphId`), call methods, get
> **`graphId` is required (server 0.7.0).** `omnigraph-server` is cluster-only: every graph-scoped operation is served under `/graphs/{graphId}/…`. A graph-scoped call without a `graphId` throws `ConfigurationError` before hitting the network. Only `og.health()` and `og.graphs.list()` work without one — use the latter to discover ids, then [`og.graph(id)`](#multi-graph-clusters). This SDK targets the matching server release (see [Server compatibility](#server-compatibility)); 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.
+Use **`og.query()`** (read), **`og.mutate()`** (write), and **`og.load()`** (bulk load). Deprecated aliases were removed from the SDK in v0.7; v0.10 does not promise compatibility with older server minor versions.
## What you can do
@@ -47,9 +50,8 @@ That's the whole pattern: instantiate once (with a `graphId`), call methods, get
```ts
const { rows, columns, rowCount } = await og.query({
branch: 'main',
- query: 'query top($limit: I32) { ... order by $p.score desc limit $limit }',
+ query: 'query top() { match { $p: Person } return { $p.name, $p.age } order { $p.age desc } limit 10 }',
name: 'top',
- params: { limit: 10 },
});
```
@@ -66,7 +68,29 @@ const { affectedNodes, affectedEdges } = await og.mutate({
});
```
-Multi-statement mutations execute atomically inside a single commit.
+Multi-statement mutations publish atomically. Successful mutations return an
+exact `commit` receipt; `commit: null` means a successful no-op. Load methods
+also return their exact commit, plus `nodes`, `edges`, and `totalEntities`.
+
+### Conditional mutations
+
+```ts
+const read = await og.query({
+ query: 'query person($name: String) { match { $p: Person { name: $name } } return { $p.age } }',
+ params: { name: 'Alice' },
+});
+if (!read.graphCommitId) throw new Error('Read has no graph commit position');
+await og.mutate({
+ query: 'query birthday($name: String, $age: I32) { update Person set { age: $age } where name = $name }',
+ params: { name: 'Alice', age: 31 },
+}, { ifGraphCommit: read.graphCommitId });
+```
+
+The SDK uses `/mutate/if-graph-commit`, never an optional header on ordinary
+`/mutate`. A stale position throws `PreconditionFailedError` (412) with
+`preconditionFailure`; re-read and reconsider. An older server's 404 never
+causes an unconditional retry. Stored mutations support the same second-option
+field: `og.queries.invoke(name, input, { ifGraphCommit })`.
### Branch and merge
@@ -86,7 +110,7 @@ import { LoadMode } from '@modernrelay/omnigraph';
await og.load({
branch: 'import-2026-04-30',
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
+ mode: LoadMode.MERGE, // upsert by @key; not request deduplication
data: ndjsonString,
});
```
@@ -99,10 +123,11 @@ For high-rate pipelines there is also `og.loadNdjson()` (server 0.9.0+), which p
await og.loadNdjson({
branch: 'ingest',
from: 'main', // same fork-if-missing rule as og.load()
- mode: LoadMode.MERGE, // default; upsert by @key — safe to retry
+ mode: LoadMode.MERGE, // default; reconcile ambiguous outcomes before retry
ndjson:
- '{"type":"Person","data":{"name":"Ada"}}\n' +
- '{"edge":"Knows","from":"ada","to":"grace","data":{}}\n',
+ '{"type":"Person","data":{"name":"Ada","age":30}}\n' +
+ '{"type":"Person","data":{"name":"Grace","age":35}}\n' +
+ '{"edge":"Knows","from":"Ada","to":"Grace","data":{}}\n',
});
```
@@ -111,7 +136,7 @@ Each nonblank line is exactly one node envelope (`{"type":...,"data":{...}}`) or
### Stream a branch as NDJSON
```ts
-for await (const row of og.export({ branch: 'main' })) {
+for await (const row of og.export({ branch: 'main', typeNames: ['Person'] })) {
// row keys reflect your schema verbatim
}
```
@@ -134,9 +159,64 @@ await og.commits.list({ branch: 'main' });
await og.commits.retrieve(commitId);
```
+Snapshots expose `graphBranch`, `graphManifestVersion`, and `datasets`.
+Each dataset reports `entityKind`, `typeName`, `entityCount`, `datasetPath`,
+`publishedDatasetVersion`, and optional `nativeDatasetBranch`. The published
+version is graph authority, not an observation of the current physical head.
+
+### Entity changes and baselines
+
+```ts
+const diff = await og.commits.changes(commitId, { kind: ['node'], limit: 100 });
+const page = await og.changes.poll({ branch: 'main', start: 'now' });
+// Resume a later poll using the terminal page's cursor:
+if (page.cursor) await og.changes.poll({ branch: 'main', cursor: page.cursor });
+```
+
+These return **one bounded page**, not an automatically accumulated history.
+Follow `nextPageToken` with `pageToken`, preserving branch and filters; omit
+`start` and `cursor` while continuing pages. Entity `properties` remain verbatim,
+including underscore keys. Commit-diff page tokens are not feed cursors.
+Apply feed blocks idempotently by `graphCommitId` and persist the terminal
+cursor atomically with the applied data. HTTP 410 `GoneError.changeFeedGap`
+requires a fresh baseline, not a retry of the same cursor.
+
+`og.changes.baseline({ branch: 'main' })` streams typed `ChangeBaselineRecord`
+values: node/edge export records followed by `{ baseline: { snapshotCommitId,
+resumeCursor } }`. It exposes the terminal record only after clean stream
+completion and rejects a missing or malformed terminal record. Install the
+complete entity snapshot durably **before** saving its resume cursor. The SDK
+does not own consumer storage or checkpoint durability.
+
+### Blob bytes and metadata
+
+```ts
+const selector = { entity: 'node' as const, type: 'Document', id: 'manual', property: 'content' };
+const metadata = await og.blobs.stat(selector); // HEAD, no payload
+const response = await og.blobs.get({ ...selector, range: 'bytes=0-1023' });
+if (response.status === 200 || response.status === 206) {
+ // response.body is a ReadableStream; consume incrementally for large blobs.
+} else if (response.status === 302) {
+ // External reference only. Decide separately whether to access Location.
+} else if (response.status === 304) {
+ // Cached representation matched ifNoneMatch.
+}
+```
+
+Both methods return the raw `Response`, preserve headers/ETags, and never
+follow external redirects. Inputs support `branch` or `snapshot`, `ifMatch`,
+and `ifNoneMatch`; GET also supports `range` and `ifRange`. `snapshot` takes a
+graph commit ID, such as `query().graphCommitId`; the opaque
+`Omnigraph-Snapshot-Id` response header is diagnostic identity, not a reusable
+request value. A failed condition
+or range throws a typed 412/416 error. HEAD errors have no JSON body, so inspect
+the status and response headers. Write Blob values through normal mutate/load;
+there is no Blob-write or full-text-index-rebuild HTTP endpoint.
+
## Errors
-Every method throws a typed error subclass on non-2xx. Catch the specific class you care about:
+Methods throw typed errors on HTTP failure (Blob 302/304 are explicit successes).
+Catch the specific class you care about:
```ts
import {
@@ -164,6 +244,17 @@ try {
Every error carries `status`, `code`, `requestId` (from the `X-Request-Id` response header), and the parsed response body for diagnostics.
+New status-specific classes are `GoneError` (410), `PreconditionFailedError`
+(412), `PayloadTooLargeError` (413), `RangeNotSatisfiableError` (416),
+`FailedDependencyError` (424), and `ServiceUnavailableError` (503). They retain
+their structured details, such as `resourceLimit` or `recoveryRequired`.
+HTTP status takes priority over the server's older broad `code` values.
+
+For 409s, inspect `ConflictError.publishedDatasetVersionConflict`,
+`readSetConflict`, `keyConflict`, `mergeConflicts`, `changeDiffRefusal`, or
+`fullTextIndexRebuildRequired`. A full-text rebuild refusal requires explicit
+operator maintenance on the relevant branch; retrying search cannot repair it.
+
`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
@@ -194,26 +285,48 @@ if (sdkMm !== srvMm) {
}
```
-`@modernrelay/omnigraph@X.Y.Z` is built from `omnigraph-server@X.Y.Z` and is expected to work against any `>=X.Y.0, {
+ mutate(input: MutationInput, opts: ConditionalCallOptions = {}): Promise {
+ if (opts.ifGraphCommit !== undefined) {
+ return this.t.request('POST', '/mutate/if-graph-commit', {
+ body: input,
+ headers: { 'Omnigraph-If-Graph-Commit': opts.ifGraphCommit },
+ signal: opts.signal,
+ opaqueBodyKeys: OPAQUE_PARAMS,
+ });
+ }
return this.t.request('POST', '/mutate', {
body: input,
signal: opts.signal,
@@ -131,8 +146,8 @@ export default class Omnigraph {
/**
* 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.
+ * `mode: 'merge'` upserts entities with stable keys, but is not a request
+ * deduplication protocol. Reconcile an ambiguous response before retrying.
*
* **Branch creation is opt-in.** Without `from`, the target `branch` must
* already exist — a missing branch is a {@link NotFoundError} (404), never an
@@ -153,8 +168,8 @@ export default class Omnigraph {
* Bounded like every keyed load — an oversized batch is refused with a
* 413 before any durable effect; split it across requests.
*
- * `mode` defaults to `merge` (**use it for at-least-once safety** — retries
- * upsert by `@key` instead of duplicating rows). **Branch creation is
+ * `mode` defaults to `merge` (upserts stable keys, not request deduplication).
+ * Reconcile an ambiguous response before retrying. **Branch creation is
* opt-in**: without `from`, the target `branch` must already exist.
*/
loadNdjson(input: LoadNdjsonInput, opts: CallOptions = {}): Promise {
diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts
index db8b67d..3387840 100644
--- a/packages/sdk/src/errors.ts
+++ b/packages/sdk/src/errors.ts
@@ -1,4 +1,4 @@
-import type { ErrorCode, ErrorOutput, ManifestConflict, MergeConflict } from './types';
+import type { ErrorCode, ErrorOutput } from './types';
export interface OmnigraphErrorContext {
status: number;
@@ -37,19 +37,71 @@ export class NotFoundError extends OmnigraphError {}
export class MethodNotAllowedError extends OmnigraphError {}
export class ConflictError extends OmnigraphError {
- readonly mergeConflicts?: MergeConflict[];
- /**
- * Set when the conflict is a publisher OCC rejection: the caller's pre-write
- * view of `tableKey` was at `expected`, but the manifest is now at `actual`.
- * Refresh and retry.
- */
- readonly manifestConflict?: ManifestConflict;
+ get mergeConflicts() {
+ return (this.body as ErrorOutput | undefined)?.mergeConflicts;
+ }
+ get publishedDatasetVersionConflict() {
+ return (
+ (this.body as ErrorOutput | undefined)?.publishedDatasetVersionConflict ??
+ undefined
+ );
+ }
+ get readSetConflict() {
+ return (this.body as ErrorOutput | undefined)?.readSetConflict ?? undefined;
+ }
+ get keyConflict() {
+ return (this.body as ErrorOutput | undefined)?.keyConflict ?? undefined;
+ }
+ get changeDiffRefusal() {
+ return (
+ (this.body as ErrorOutput | undefined)?.changeDiffRefusal ?? undefined
+ );
+ }
+ /** This conflict needs operator maintenance, not a retry. */
+ get fullTextIndexRebuildRequired() {
+ return (
+ (this.body as ErrorOutput | undefined)?.fullTextIndexRebuildRequired ??
+ undefined
+ );
+ }
+}
- constructor(ctx: OmnigraphErrorContext) {
- super(ctx);
- const body = ctx.body as ErrorOutput | undefined;
- this.mergeConflicts = body?.mergeConflicts;
- this.manifestConflict = body?.manifestConflict ?? undefined;
+/** Retained change history is unavailable; recover through a baseline. */
+export class GoneError extends OmnigraphError {
+ get changeFeedGap() {
+ return (this.body as ErrorOutput | undefined)?.changeFeedGap ?? undefined;
+ }
+}
+/** Stale graph-head precondition (or a failed Blob If-Match). */
+export class PreconditionFailedError extends OmnigraphError {
+ get preconditionFailure() {
+ return (
+ (this.body as ErrorOutput | undefined)?.preconditionFailure ?? undefined
+ );
+ }
+}
+export class PayloadTooLargeError extends OmnigraphError {
+ get resourceLimit() {
+ return (this.body as ErrorOutput | undefined)?.resourceLimit ?? undefined;
+ }
+}
+export class RangeNotSatisfiableError extends OmnigraphError {
+ get blobRange() {
+ return (this.body as ErrorOutput | undefined)?.blobRange ?? undefined;
+ }
+}
+export class FailedDependencyError extends OmnigraphError {
+ get externalBlobSource() {
+ return (
+ (this.body as ErrorOutput | undefined)?.externalBlobSource ?? undefined
+ );
+ }
+}
+export class ServiceUnavailableError extends OmnigraphError {
+ get recoveryRequired() {
+ return (
+ (this.body as ErrorOutput | undefined)?.recoveryRequired ?? undefined
+ );
}
}
@@ -68,7 +120,10 @@ export class NetworkError extends OmnigraphError {}
*/
export class ConfigurationError extends OmnigraphError {}
-const codeToClass: Record OmnigraphError> = {
+const codeToClass: Record<
+ ErrorCode,
+ new (ctx: OmnigraphErrorContext) => OmnigraphError
+> = {
bad_request: BadRequestError,
unauthorized: UnauthorizedError,
forbidden: ForbiddenError,
@@ -79,15 +134,24 @@ const codeToClass: Record Omnigra
internal: InternalServerError,
};
-const statusToClass: Record OmnigraphError> = {
+const statusToClass: Record<
+ number,
+ new (ctx: OmnigraphErrorContext) => OmnigraphError
+> = {
400: BadRequestError,
401: UnauthorizedError,
403: ForbiddenError,
404: NotFoundError,
405: MethodNotAllowedError,
409: ConflictError,
+ 410: GoneError,
+ 412: PreconditionFailedError,
+ 413: PayloadTooLargeError,
+ 416: RangeNotSatisfiableError,
+ 424: FailedDependencyError,
429: TooManyRequestsError,
500: InternalServerError,
+ 503: ServiceUnavailableError,
};
export function fromResponse(args: {
@@ -100,9 +164,13 @@ export function fromResponse(args: {
const body = args.body as ErrorOutput | undefined;
const code = body?.code ?? null;
const message = body?.error ?? `HTTP ${args.status}`;
+ // Several v0.10 statuses deliberately retain an older broad code (e.g.
+ // 413/416 use bad_request and Blob 412 uses conflict). Status is specific.
const Ctor =
- (code ? codeToClass[code] : undefined) ??
statusToClass[args.status] ??
+ (code && Object.hasOwn(codeToClass, code)
+ ? codeToClass[code]
+ : undefined) ??
InternalServerError;
return new Ctor({
status: args.status,
diff --git a/packages/sdk/src/generated/index.ts b/packages/sdk/src/generated/index.ts
index 04388b9..5194cd2 100644
--- a/packages/sdk/src/generated/index.ts
+++ b/packages/sdk/src/generated/index.ts
@@ -1,6 +1,8 @@
// This file is auto-generated by @hey-api/openapi-ts
export {
+ BlobEntityKind,
+ type BlobRangeOutput,
type BranchCreateOutput,
type BranchCreateRequest,
type BranchDeleteOutput,
@@ -8,14 +10,33 @@ export {
BranchMergeOutcome,
type BranchMergeOutput,
type BranchMergeRequest,
+ type ChangeBaselineOutput,
+ type ChangeBaselineRecord,
+ type ChangeBaselineRequest,
+ type ChangeBlockOutput,
+ type ChangeCauseOutput,
+ type ChangeDiffRefusalOutput,
+ ChangeDiffRefusalReason,
+ type ChangeEndpointsOutput,
+ type ChangeErrorOutput,
+ type ChangeFeedGapOutput,
+ type ChangeFeedOutput,
+ type ChangeImageOutput,
+ ChangeOpOutput,
type ChangeOutput,
type ChangeRequest,
+ type ChangeTypeOutput,
type ClientOptions,
type ClusterApplySchemaData,
type ClusterApplySchemaError,
type ClusterApplySchemaErrors,
type ClusterApplySchemaResponse,
type ClusterApplySchemaResponses,
+ type ClusterCaptureChangeBaselineData,
+ type ClusterCaptureChangeBaselineError,
+ type ClusterCaptureChangeBaselineErrors,
+ type ClusterCaptureChangeBaselineResponse,
+ type ClusterCaptureChangeBaselineResponses,
type ClusterChangeData,
type ClusterChangeError,
type ClusterChangeErrors,
@@ -35,6 +56,16 @@ export {
type ClusterExportError,
type ClusterExportErrors,
type ClusterExportResponses,
+ type ClusterGetBlobData,
+ type ClusterGetBlobError,
+ type ClusterGetBlobErrors,
+ type ClusterGetBlobResponse,
+ type ClusterGetBlobResponses,
+ type ClusterGetCommitChangesData,
+ type ClusterGetCommitChangesError,
+ type ClusterGetCommitChangesErrors,
+ type ClusterGetCommitChangesResponse,
+ type ClusterGetCommitChangesResponses,
type ClusterGetCommitData,
type ClusterGetCommitError,
type ClusterGetCommitErrors,
@@ -50,6 +81,9 @@ export {
type ClusterGetSnapshotErrors,
type ClusterGetSnapshotResponse,
type ClusterGetSnapshotResponses,
+ type ClusterHeadBlobData,
+ type ClusterHeadBlobErrors,
+ type ClusterHeadBlobResponses,
type ClusterIngestData,
type ClusterIngestError,
type ClusterIngestErrors,
@@ -58,6 +92,11 @@ export {
type ClusterInvokeQueryData,
type ClusterInvokeQueryError,
type ClusterInvokeQueryErrors,
+ type ClusterInvokeQueryIfGraphCommitData,
+ type ClusterInvokeQueryIfGraphCommitError,
+ type ClusterInvokeQueryIfGraphCommitErrors,
+ type ClusterInvokeQueryIfGraphCommitResponse,
+ type ClusterInvokeQueryIfGraphCommitResponses,
type ClusterInvokeQueryResponse,
type ClusterInvokeQueryResponses,
type ClusterListBranchesData,
@@ -93,8 +132,18 @@ export {
type ClusterMutateData,
type ClusterMutateError,
type ClusterMutateErrors,
+ type ClusterMutateIfGraphCommitData,
+ type ClusterMutateIfGraphCommitError,
+ type ClusterMutateIfGraphCommitErrors,
+ type ClusterMutateIfGraphCommitResponse,
+ type ClusterMutateIfGraphCommitResponses,
type ClusterMutateResponse,
type ClusterMutateResponses,
+ type ClusterPollChangesData,
+ type ClusterPollChangesError,
+ type ClusterPollChangesErrors,
+ type ClusterPollChangesResponse,
+ type ClusterPollChangesResponses,
type ClusterQueryData,
type ClusterQueryError,
type ClusterQueryErrors,
@@ -105,11 +154,16 @@ export {
type ClusterReadErrors,
type ClusterReadResponse,
type ClusterReadResponses,
+ type CommitChangesOutput,
type CommitListOutput,
type CommitOutput,
+ type EntityChangeOutput,
+ EntityKindOutput,
ErrorCode,
type ErrorOutput,
type ExportRequest,
+ type ExternalBlobSourceOutput,
+ type FullTextIndexRebuildRequiredOutput,
type GraphBatchDeclarationOutput,
type GraphBatchLoadOutput,
type GraphInfo,
@@ -120,21 +174,22 @@ export {
type HealthResponses,
type IngestOutput,
type IngestRequest,
- type IngestTableOutput,
type InvokeStoredQueryRequest,
type InvokeStoredQueryResponse,
type KeyConflictOutput,
+ type LegacyReadOutput,
type ListGraphsData,
type ListGraphsError,
type ListGraphsErrors,
type ListGraphsResponse,
type ListGraphsResponses,
LoadMode,
- type ManifestConflictOutput,
MergeConflictKindOutput,
type MergeConflictOutput,
type ParamDescriptor,
ParamKind,
+ type PreconditionFailureOutput,
+ type PublishedDatasetVersionConflictOutput,
type QueriesCatalogOutput,
type QueryCatalogEntry,
type QueryRequest,
@@ -147,6 +202,6 @@ export {
type SchemaApplyOutput,
type SchemaApplyRequest,
type SchemaOutput,
+ type SnapshotDatasetOutput,
type SnapshotOutput,
- type SnapshotTableOutput,
} from "./types.gen";
diff --git a/packages/sdk/src/generated/types.gen.ts b/packages/sdk/src/generated/types.gen.ts
index 5563a49..ed45cb2 100644
--- a/packages/sdk/src/generated/types.gen.ts
+++ b/packages/sdk/src/generated/types.gen.ts
@@ -4,6 +4,33 @@ export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {});
};
+/**
+ * Logical graph entity selected by the Blob delivery surface.
+ *
+ * This is intentionally graph vocabulary: callers select a node or edge.
+ */
+export const BlobEntityKind = { NODE: "node", EDGE: "edge" } as const;
+
+/**
+ * Logical graph entity selected by the Blob delivery surface.
+ *
+ * This is intentionally graph vocabulary: callers select a node or edge.
+ */
+export type BlobEntityKind =
+ (typeof BlobEntityKind)[keyof typeof BlobEntityKind];
+
+/**
+ * Normalized half-open range details for an unsatisfiable managed Blob read.
+ *
+ * HTTP also returns `Content-Range: bytes *N`; these fields let SDKs inspect
+ * the failure without parsing either that header or the human-readable text.
+ */
+export type BlobRangeOutput = {
+ end: number;
+ length: number;
+ start: number;
+};
+
export type BranchCreateOutput = {
actor_id?: string | null;
from: string;
@@ -78,11 +105,193 @@ export type BranchMergeRequest = {
target?: string | null;
};
+/**
+ * Terminal payload of a baseline stream: the captured snapshot commit and
+ * the cursor that resumes the feed immediately after it.
+ */
+export type ChangeBaselineOutput = {
+ resume_cursor: string;
+ snapshot_commit_id: string;
+};
+
+/**
+ * Wire envelope of the FINAL baseline stream line: `{"baseline": {...}}`,
+ * distinguishable from snapshot records (which carry `type`/`edge` keys).
+ * Emitted exactly once, only after every snapshot record — an interrupted
+ * stream has no terminal record and therefore no usable cursor.
+ */
+export type ChangeBaselineRecord = {
+ baseline: ChangeBaselineOutput;
+};
+
+/**
+ * Body for the change baseline handshake.
+ */
+export type ChangeBaselineRequest = {
+ /**
+ * Branch to capture. Defaults to `main`.
+ */
+ branch?: string | null;
+ /**
+ * Feed scope the resume cursor is bound to. The snapshot honors `kind`
+ * and `type`; `op` constrains only subsequent polls.
+ */
+ kind?: Array;
+ op?: Array;
+ type?: Array;
+};
+
+/**
+ * One commit block inside a feed page.
+ */
+export type ChangeBlockOutput = {
+ cause: ChangeCauseOutput;
+ changes: Array;
+};
+
+/**
+ * The commit cause of one change block, stated once.
+ */
+export type ChangeCauseOutput = {
+ actor_id?: string | null;
+ /**
+ * Authorship time as Unix epoch microseconds — minted before dataset
+ * effects and stable across retries; deliberately not labeled a commit or
+ * publication time.
+ */
+ authored_at: number;
+ /**
+ * The branch the commit originally landed on (not the requested branch).
+ */
+ authored_branch: string;
+ graph_commit_id: string;
+ merged_parent_commit_id?: string | null;
+ parent_commit_id?: string | null;
+};
+
+/**
+ * A well-formed entity-diff request this commit cannot satisfy (HTTP 409).
+ */
+export type ChangeDiffRefusalOutput = {
+ graph_commit_id: string;
+ reason: ChangeDiffRefusalReason;
+ /**
+ * The graph type at the schema boundary, when the reason names one.
+ */
+ type_name?: string | null;
+};
+
+/**
+ * Why a well-formed entity-diff request was refused (HTTP 409).
+ */
+export const ChangeDiffRefusalReason = {
+ PARENTLESS_COMMIT: "parentless_commit",
+ SCHEMA_BOUNDARY: "schema_boundary",
+ UNKNOWN: "unknown",
+} as const;
+
+/**
+ * Why a well-formed entity-diff request was refused (HTTP 409).
+ */
+export type ChangeDiffRefusalReason =
+ (typeof ChangeDiffRefusalReason)[keyof typeof ChangeDiffRefusalReason];
+
+/**
+ * Edge endpoints as graph references. Endpoints belong to each image, so an
+ * endpoint-moving update has distinct before and after endpoints.
+ */
+export type ChangeEndpointsOutput = {
+ from: string;
+ to: string;
+};
+
+/**
+ * Error envelope for the read-only change surfaces (`…/changes`,
+ * `…/changes/baseline`, `…/commits/{commit_id}/changes`): a wire-compatible
+ * projection of [`ErrorOutput`] restricted to the graph-vocabulary details
+ * those routes can produce after their error projection. The write-path
+ * conflict shapes (key / published-dataset-version / merge / read-set) are
+ * structurally absent because change routes cannot produce them. Servers
+ * serialize [`ErrorOutput`]; every field a change route can populate appears
+ * here with the same name and meaning, and absent optionals are wire-compatible.
+ */
+export type ChangeErrorOutput = {
+ change_diff_refusal?: null | ChangeDiffRefusalOutput;
+ change_feed_gap?: null | ChangeFeedGapOutput;
+ code?: null | ErrorCode;
+ error: string;
+ recovery_required?: null | RecoveryRequiredOutput;
+ resource_limit?: null | ResourceLimitOutput;
+};
+
+/**
+ * A change continuation can no longer be reconstructed from retained history
+ * (HTTP 410). Recovery is the baseline handshake; retrying the same cursor
+ * cannot succeed. `code` stays unset: [`ErrorCode`] is closed and this
+ * additive detail is the machine-readable discriminator (the same rolling
+ * contract as `external_blob_source`).
+ */
+export type ChangeFeedGapOutput = {
+ cursor?: string | null;
+ first_unreadable_commit_id: string;
+};
+
+/**
+ * One bounded feed poll result.
+ */
+export type ChangeFeedOutput = {
+ blocks: Array;
+ /**
+ * Present with `cursor` on a terminal page: true when the page reached
+ * its captured head, false when more complete commits already wait.
+ */
+ caught_up?: boolean | null;
+ /**
+ * Durable caller-owned cursor, advanced only over complete commits and
+ * returned only on a terminal page — an interrupted poll never advances
+ * it.
+ */
+ cursor?: string | null;
+ /**
+ * Continue this poll's captured cut. Absent on a terminal page.
+ */
+ next_page_token?: string | null;
+};
+
+/**
+ * One exact logical entity image, decoded with the commit-era schema.
+ */
+export type ChangeImageOutput = {
+ endpoints?: null | ChangeEndpointsOutput;
+ /**
+ * Exact logical property values; user-schema keys verbatim.
+ */
+ properties: unknown;
+};
+
+/**
+ * Logical operation of one change. Ordering rank is frozen:
+ * insert before update before delete within one entity.
+ */
+export const ChangeOpOutput = {
+ INSERT: "insert",
+ UPDATE: "update",
+ DELETE: "delete",
+} as const;
+
+/**
+ * Logical operation of one change. Ordering rank is frozen:
+ * insert before update before delete within one entity.
+ */
+export type ChangeOpOutput =
+ (typeof ChangeOpOutput)[keyof typeof ChangeOpOutput];
+
export type ChangeOutput = {
actor_id?: string | null;
affected_edges: number;
affected_nodes: number;
branch: string;
+ commit?: null | CommitOutput;
query_name: string;
};
@@ -110,6 +319,28 @@ export type ChangeRequest = {
query: string;
};
+/**
+ * Graph-scoped type identity. `id` is opaque: it survives a supported rename
+ * and changes after drop/re-add. It is not a type-name selector or path.
+ */
+export type ChangeTypeOutput = {
+ id: string;
+ name: string;
+};
+
+/**
+ * One bounded page of the finite commit entity diff.
+ */
+export type CommitChangesOutput = {
+ cause: ChangeCauseOutput;
+ changes: Array;
+ /**
+ * Continue THIS bounded response. Absent on the final page. Never a feed
+ * cursor.
+ */
+ next_page_token?: string | null;
+};
+
export type CommitListOutput = {
commits: Array;
};
@@ -120,13 +351,38 @@ export type CommitOutput = {
* Commit creation time as Unix epoch microseconds.
*/
created_at: number;
+ graph_branch?: string | null;
graph_commit_id: string;
- manifest_branch?: string | null;
- manifest_version: number;
+ graph_manifest_version: number;
merged_parent_commit_id?: string | null;
parent_commit_id?: string | null;
};
+/**
+ * One entity change. Cause is stated once on the enclosing block, never here.
+ * An insert carries only `after`, an update exact `before` and `after`, a
+ * delete only `before`.
+ */
+export type EntityChangeOutput = {
+ after?: null | ChangeImageOutput;
+ before?: null | ChangeImageOutput;
+ id: string;
+ kind: EntityKindOutput;
+ op: ChangeOpOutput;
+ type: ChangeTypeOutput;
+};
+
+/**
+ * Logical graph entity namespace.
+ */
+export const EntityKindOutput = { NODE: "node", EDGE: "edge" } as const;
+
+/**
+ * Logical graph entity namespace.
+ */
+export type EntityKindOutput =
+ (typeof EntityKindOutput)[keyof typeof EntityKindOutput];
+
export const ErrorCode = {
UNAUTHORIZED: "unauthorized",
FORBIDDEN: "forbidden",
@@ -141,11 +397,17 @@ export const ErrorCode = {
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
export type ErrorOutput = {
+ blob_range?: null | BlobRangeOutput;
+ change_diff_refusal?: null | ChangeDiffRefusalOutput;
+ change_feed_gap?: null | ChangeFeedGapOutput;
code?: null | ErrorCode;
error: string;
+ external_blob_source?: null | ExternalBlobSourceOutput;
+ full_text_index_rebuild_required?: null | FullTextIndexRebuildRequiredOutput;
key_conflict?: null | KeyConflictOutput;
- manifest_conflict?: null | ManifestConflictOutput;
merge_conflicts?: Array;
+ precondition_failure?: null | PreconditionFailureOutput;
+ published_dataset_version_conflict?: null | PublishedDatasetVersionConflictOutput;
read_set_conflict?: null | ReadSetConflictOutput;
recovery_required?: null | RecoveryRequiredOutput;
resource_limit?: null | ResourceLimitOutput;
@@ -156,25 +418,53 @@ export type ExportRequest = {
* Branch to export. Defaults to `main`.
*/
branch?: string | null;
- /**
- * Restrict the export to these table keys. Empty exports all tables.
- */
- table_keys?: Array;
/**
* Restrict the export to these node/edge type names. Empty exports all types.
*/
type_names?: Array;
};
+/**
+ * Structured details for an allowed external Blob source that could not be
+ * probed or read. The top-level `code` remains optional so this additive
+ * detail can roll out without extending the closed [`ErrorCode`] enum.
+ */
+export type ExternalBlobSourceOutput = {
+ /**
+ * Source-side failure diagnosis. Clients should branch on the presence of
+ * `external_blob_source`, not parse this human-readable text.
+ */
+ reason: string;
+ /**
+ * Normalized, credential-free URI spelling (or a redacted placeholder).
+ */
+ uri: string;
+};
+
+/**
+ * A selected full-text index cannot safely serve the current analyzer (HTTP 409).
+ * This is not a retryable write conflict: an operator must rebuild the live
+ * branch's indexes. Historical snapshots stay unchanged; branch old content
+ * and rebuild that branch to search it.
+ */
+export type FullTextIndexRebuildRequiredOutput = {
+ index: string;
+ /**
+ * Human-readable diagnosis; branch on the enclosing detail's presence,
+ * not this text, to distinguish the operator-action-required condition.
+ */
+ reason: string;
+};
+
/**
* One logical declaration touched by a graph-batch load.
*
- * This deliberately carries the accepted-schema name, not the backing
- * manifest table key, dataset path, or Lance identity.
+ * This deliberately carries the accepted-schema name, not a backing dataset
+ * selector, path, or Lance identity.
*/
export type GraphBatchDeclarationOutput = {
+ entities_loaded: number;
name: string;
- rows_loaded: number;
};
/**
@@ -189,6 +479,7 @@ export type GraphBatchLoadOutput = {
base_branch?: string | null;
branch: string;
branch_created: boolean;
+ commit?: null | CommitOutput;
/**
* Logical edge declarations touched by this batch, sorted by name.
*/
@@ -198,7 +489,7 @@ export type GraphBatchLoadOutput = {
* Logical node declarations touched by this batch, sorted by name.
*/
nodes: Array;
- total_rows: number;
+ total_entities: number;
};
/**
@@ -240,8 +531,17 @@ export type IngestOutput = {
base_branch?: string | null;
branch: string;
branch_created: boolean;
+ commit?: null | CommitOutput;
+ /**
+ * Logical edge declarations touched by this load, sorted by name.
+ */
+ edges: Array;
mode: LoadMode;
- tables: Array;
+ /**
+ * Logical node declarations touched by this load, sorted by name.
+ */
+ nodes: Array;
+ total_entities: number;
uri: string;
};
@@ -265,11 +565,6 @@ export type IngestRequest = {
mode?: null | LoadMode;
};
-export type IngestTableOutput = {
- rows_loaded: number;
- table_key: string;
-};
-
/**
* Body for `POST /queries/{name}` — invokes the server-side stored query
* named in the path. The query source and name come from the registry,
@@ -310,13 +605,27 @@ export type InvokeStoredQueryRequest = {
export type InvokeStoredQueryResponse = ReadOutput | ChangeOutput;
/**
- * A strict insert rejected because `key` already names a row in the keyed
- * graph table. The operation is effect-free when this output is returned;
+ * A strict insert rejected because `entity_id` already names an entity in the
+ * selected node or edge type. The operation is effect-free when this output is returned;
* partial or ambiguous attempts surface `recovery_required` instead.
*/
export type KeyConflictOutput = {
- key?: string | null;
- table_key: string;
+ entity_id?: string | null;
+ entity_kind: EntityKindOutput;
+ type_name: string;
+};
+
+/**
+ * Indefinitely byte-stable response shape for the deprecated `POST /read`
+ * route. The canonical [`ReadOutput`] may grow additive fields; this legacy
+ * envelope deliberately cannot carry them.
+ */
+export type LegacyReadOutput = {
+ columns?: Array;
+ query_name: string;
+ row_count: number;
+ rows: unknown;
+ target: ReadTargetOutput;
};
/**
@@ -333,18 +642,6 @@ export const LoadMode = {
*/
export type LoadMode = (typeof LoadMode)[keyof typeof LoadMode];
-/**
- * Structured details for a publisher-level OCC failure. Surfaces alongside
- * HTTP 409 when a write was rejected because the caller's pre-write view of
- * one table's manifest version was stale relative to the current head. The
- * expected/actual fields tell the client which table to refresh.
- */
-export type ManifestConflictOutput = {
- actual: number;
- expected: number;
- table_key: string;
-};
-
export const MergeConflictKindOutput = {
DIVERGENT_INSERT: "divergent_insert",
DIVERGENT_UPDATE: "divergent_update",
@@ -359,10 +656,11 @@ export type MergeConflictKindOutput =
(typeof MergeConflictKindOutput)[keyof typeof MergeConflictKindOutput];
export type MergeConflictOutput = {
+ entity_id?: string | null;
+ entity_kind: EntityKindOutput;
kind: MergeConflictKindOutput;
message: string;
- row_id?: string | null;
- table_key: string;
+ type_name: string;
};
/**
@@ -413,6 +711,30 @@ export const ParamKind = {
*/
export type ParamKind = (typeof ParamKind)[keyof typeof ParamKind];
+/**
+ * Structured details for a caller write-precondition failure: HTTP 412, a
+ * mutation carried `Omnigraph-If-Graph-Commit: `, and the branch
+ * head no longer matches that id. The write had no effect; the caller re-reads
+ * the branch and decides again. `actual` is `None` on a branch with no commits.
+ */
+export type PreconditionFailureOutput = {
+ actual?: string | null;
+ expected: string;
+};
+
+/**
+ * Structured details for a publisher-level OCC failure. Surfaces alongside
+ * HTTP 409 when a write was rejected because the caller's pre-write view of
+ * one backing dataset's published version was stale relative to the current
+ * head. The expected/actual fields tell the client which dataset to refresh.
+ */
+export type PublishedDatasetVersionConflictOutput = {
+ actual_published_dataset_version: number;
+ entity_kind: EntityKindOutput;
+ expected_published_dataset_version: number;
+ type_name: string;
+};
+
/**
* Response for `GET /queries`: every stored query in a graph's
* registry, each with typed parameters.
@@ -480,6 +802,14 @@ export type QueryRequest = {
export type ReadOutput = {
columns?: Array;
+ /**
+ * Effective graph head commit id of the exact snapshot this read was
+ * served from. On a fresh named branch this is the inherited source head,
+ * so it is immediately usable as `Omnigraph-If-Graph-Commit` (CLI:
+ * `--if-commit`) for the branch's first conditional write. The id and rows
+ * come from one pinned version, so no separate id fetch is needed.
+ */
+ graph_commit_id?: string | null;
query_name: string;
row_count: number;
rows: unknown;
@@ -514,7 +844,7 @@ export type ReadRequest = {
/**
* Structured authority mismatch for a prepared write. Values are
* strings because members include optional graph commit ids and future
- * authority tokens, not only numeric table versions.
+ * authority tokens, not only numeric published dataset versions.
*/
export type ReadSetConflictOutput = {
actual?: string | null;
@@ -533,7 +863,7 @@ export type RecoveryRequiredOutput = {
/**
* A write rejected before durable recovery ownership because its bounded
- * physical plan exceeded an explicit row, byte, or transaction-chain ceiling.
+ * physical plan exceeded an explicit entity, byte, or transaction-chain ceiling.
*/
export type ResourceLimitOutput = {
actual: number;
@@ -543,7 +873,7 @@ export type ResourceLimitOutput = {
export type SchemaApplyOutput = {
applied: boolean;
- manifest_version: number;
+ graph_manifest_version: number;
step_count: number;
steps: Array;
supported: boolean;
@@ -553,7 +883,7 @@ export type SchemaApplyOutput = {
export type SchemaApplyRequest = {
/**
* When true, promote every `DropMode::Soft` step in the plan to
- * `DropMode::Hard`, making the prior column data unreachable
+ * `DropMode::Hard`, making the prior property data unreachable
* after the apply. Matches the CLI's `--allow-data-loss` flag.
* Defaults to `false` (drops remain reversible via time travel).
*/
@@ -569,23 +899,24 @@ export type SchemaOutput = {
schema_source: string;
};
+export type SnapshotDatasetOutput = {
+ dataset_path: string;
+ entity_count: number;
+ entity_kind: EntityKindOutput;
+ native_dataset_branch?: string | null;
+ published_dataset_version: number;
+ type_name: string;
+};
+
export type SnapshotOutput = {
- branch: string;
+ datasets: Array;
+ graph_branch: string;
+ graph_manifest_version: number;
/**
* The on-disk internal-schema (storage-format) version this graph's branch
* is stamped at.
*/
internal_schema_version: number;
- manifest_version: number;
- tables: Array;
-};
-
-export type SnapshotTableOutput = {
- row_count: number;
- table_branch?: string | null;
- table_key: string;
- table_path: string;
- table_version: number;
};
export type ListGraphsData = {
@@ -621,6 +952,198 @@ export type ListGraphsResponses = {
export type ListGraphsResponse = ListGraphsResponses[keyof ListGraphsResponses];
+export type ClusterGetBlobData = {
+ body?: never;
+ headers?: {
+ /**
+ * Strong entity-tag-list precondition, including `*`, evaluated before If-None-Match and Range.
+ */
+ "If-Match"?: string | null;
+ /**
+ * One `bytes` range. Malformed, unknown-unit, and multiple ranges are ignored in V1.
+ */
+ Range?: string | null;
+ /**
+ * Weak entity-tag-list comparison, including `*`, evaluated before Range.
+ */
+ "If-None-Match"?: string | null;
+ /**
+ * One strong entity tag. A mismatch causes the complete representation to be served.
+ */
+ "If-Range"?: string | null;
+ };
+ path: {
+ /**
+ * Graph id to route the request to.
+ */
+ graph_id: string;
+ };
+ query: {
+ /**
+ * Select a logical node or edge cell.
+ */
+ entity: BlobEntityKind;
+ /**
+ * Accepted-schema node or edge type name.
+ */
+ type: string;
+ /**
+ * Logical entity id within the selected type.
+ */
+ id: string;
+ /**
+ * Accepted-schema Blob property name.
+ */
+ property: string;
+ /**
+ * Branch to read. Mutually exclusive with `snapshot`; defaults to `main`.
+ */
+ branch?: string;
+ /**
+ * Immutable graph snapshot id. Mutually exclusive with `branch`.
+ */
+ snapshot?: string;
+ };
+ url: "/graphs/{graph_id}/blob";
+};
+
+export type ClusterGetBlobErrors = {
+ /**
+ * Invalid selector, target, or non-Blob property
+ */
+ 400: ErrorOutput;
+ /**
+ * Unauthorized
+ */
+ 401: ErrorOutput;
+ /**
+ * Forbidden
+ */
+ 403: ErrorOutput;
+ /**
+ * Unknown entity or null Blob cell
+ */
+ 404: ErrorOutput;
+ /**
+ * If-Match did not strongly match the selected managed Blob validator
+ */
+ 412: ErrorOutput;
+ /**
+ * Requested managed byte range is unsatisfiable
+ */
+ 416: ErrorOutput;
+ /**
+ * Stored Blob integrity or pre-header delivery refusal, including ranged external descriptors that cannot be redirected
+ */
+ 500: ErrorOutput;
+};
+
+export type ClusterGetBlobError =
+ ClusterGetBlobErrors[keyof ClusterGetBlobErrors];
+
+export type ClusterGetBlobResponses = {
+ /**
+ * OpenAPI-only marker for an unstructured octet-stream response body.
+ */
+ 200: Blob | File;
+ /**
+ * OpenAPI-only marker for an unstructured octet-stream response body.
+ */
+ 206: Blob | File;
+};
+
+export type ClusterGetBlobResponse =
+ ClusterGetBlobResponses[keyof ClusterGetBlobResponses];
+
+export type ClusterHeadBlobData = {
+ body?: never;
+ headers?: {
+ /**
+ * Strong entity-tag-list precondition, including `*`, evaluated before If-None-Match.
+ */
+ "If-Match"?: string | null;
+ /**
+ * Weak entity-tag-list comparison, including `*`. Range and If-Range are ignored for HEAD.
+ */
+ "If-None-Match"?: string | null;
+ /**
+ * Accepted but ignored for HEAD; metadata always describes the complete selected Blob.
+ */
+ Range?: string | null;
+ /**
+ * Accepted but ignored for HEAD together with Range.
+ */
+ "If-Range"?: string | null;
+ };
+ path: {
+ /**
+ * Graph id to route the request to.
+ */
+ graph_id: string;
+ };
+ query: {
+ /**
+ * Select a logical node or edge cell.
+ */
+ entity: BlobEntityKind;
+ /**
+ * Accepted-schema node or edge type name.
+ */
+ type: string;
+ /**
+ * Logical entity id within the selected type.
+ */
+ id: string;
+ /**
+ * Accepted-schema Blob property name.
+ */
+ property: string;
+ /**
+ * Branch to read. Mutually exclusive with `snapshot`; defaults to `main`.
+ */
+ branch?: string;
+ /**
+ * Immutable graph snapshot id. Mutually exclusive with `branch`.
+ */
+ snapshot?: string;
+ };
+ url: "/graphs/{graph_id}/blob";
+};
+
+export type ClusterHeadBlobErrors = {
+ /**
+ * Invalid selector, target, or non-Blob property; HEAD responses have no body
+ */
+ 400: unknown;
+ /**
+ * Unauthorized; HEAD responses have no body
+ */
+ 401: unknown;
+ /**
+ * Forbidden; HEAD responses have no body
+ */
+ 403: unknown;
+ /**
+ * Unknown entity or null Blob cell; HEAD responses have no body
+ */
+ 404: unknown;
+ /**
+ * If-Match did not strongly match the selected managed Blob validator; HEAD responses have no body
+ */
+ 412: unknown;
+ /**
+ * Stored Blob integrity or pre-header delivery refusal, including ranged external descriptors that cannot be redirected; HEAD responses have no body
+ */
+ 500: unknown;
+};
+
+export type ClusterHeadBlobResponses = {
+ /**
+ * Managed Blob metadata with no response body
+ */
+ 200: unknown;
+};
+
export type ClusterListBranchesData = {
body?: never;
path: {
@@ -739,9 +1262,125 @@ export type ClusterMergeBranchesErrors = {
*/
409: ErrorOutput;
/**
- * Merge row, byte, or recovery-chain ceiling exceeded before effects
+ * Merge entity, byte, or recovery-chain ceiling exceeded before effects
+ */
+ 413: ErrorOutput;
+ /**
+ * A merge could not probe or read an allowed external Blob source
+ */
+ 424: ErrorOutput;
+ /**
+ * Per-actor admission cap exceeded; honor `Retry-After` header
+ */
+ 429: ErrorOutput;
+ /**
+ * An overlapping durable recovery intent must be resolved before retry
+ */
+ 503: ErrorOutput;
+};
+
+export type ClusterMergeBranchesError =
+ ClusterMergeBranchesErrors[keyof ClusterMergeBranchesErrors];
+
+export type ClusterMergeBranchesResponses = {
+ /**
+ * Branches merged
+ */
+ 200: BranchMergeOutput;
+};
+
+export type ClusterMergeBranchesResponse =
+ ClusterMergeBranchesResponses[keyof ClusterMergeBranchesResponses];
+
+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: "/graphs/{graph_id}/branches/{branch}";
+};
+
+export type ClusterDeleteBranchErrors = {
+ /**
+ * Unauthorized
+ */
+ 401: ErrorOutput;
+ /**
+ * Forbidden
+ */
+ 403: ErrorOutput;
+ /**
+ * Branch not found
+ */
+ 404: ErrorOutput;
+ /**
+ * Per-actor admission cap exceeded; honor `Retry-After` header
+ */
+ 429: ErrorOutput;
+ /**
+ * An overlapping durable recovery intent must be resolved before retry
+ */
+ 503: ErrorOutput;
+};
+
+export type ClusterDeleteBranchError =
+ ClusterDeleteBranchErrors[keyof ClusterDeleteBranchErrors];
+
+export type ClusterDeleteBranchResponses = {
+ /**
+ * Branch deleted
+ */
+ 200: BranchDeleteOutput;
+};
+
+export type ClusterDeleteBranchResponse =
+ ClusterDeleteBranchResponses[keyof ClusterDeleteBranchResponses];
+
+export type ClusterChangeData = {
+ body: ChangeRequest;
+ path: {
+ /**
+ * Graph id to route the request to.
+ */
+ graph_id: string;
+ };
+ query?: never;
+ url: "/graphs/{graph_id}/change";
+};
+
+export type ClusterChangeErrors = {
+ /**
+ * Bad request
+ */
+ 400: ErrorOutput;
+ /**
+ * Unauthorized
+ */
+ 401: ErrorOutput;
+ /**
+ * Forbidden
+ */
+ 403: ErrorOutput;
+ /**
+ * Write-authority conflict
+ */
+ 409: ErrorOutput;
+ /**
+ * Keyed write exceeds the per-commit entity or byte ceiling
*/
413: ErrorOutput;
+ /**
+ * An allowed external Blob source could not be probed or read
+ */
+ 424: ErrorOutput;
/**
* Per-actor admission cap exceeded; honor `Retry-After` header
*/
@@ -752,73 +1391,108 @@ export type ClusterMergeBranchesErrors = {
503: ErrorOutput;
};
-export type ClusterMergeBranchesError =
- ClusterMergeBranchesErrors[keyof ClusterMergeBranchesErrors];
+export type ClusterChangeError = ClusterChangeErrors[keyof ClusterChangeErrors];
-export type ClusterMergeBranchesResponses = {
+export type ClusterChangeResponses = {
/**
- * Branches merged
+ * Mutation results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`)
*/
- 200: BranchMergeOutput;
+ 200: ChangeOutput;
};
-export type ClusterMergeBranchesResponse =
- ClusterMergeBranchesResponses[keyof ClusterMergeBranchesResponses];
+export type ClusterChangeResponse =
+ ClusterChangeResponses[keyof ClusterChangeResponses];
-export type ClusterDeleteBranchData = {
+export type ClusterPollChangesData = {
body?: never;
path: {
/**
* Graph id to route the request to.
*/
graph_id: string;
+ };
+ query?: {
/**
- * Branch name to delete
+ * Branch whose first-parent history is polled. Defaults to `main`.
*/
- branch: string;
+ branch?: string;
+ /**
+ * Durable cursor from a prior terminal page. Mutually exclusive with
+ * `start` and `page_token`.
+ */
+ cursor?: string;
+ /**
+ * Explicit start mode: `now` (default) | `beginning` |
+ * `after:`. Mutually exclusive with `cursor` and `page_token`.
+ */
+ start?: string;
+ /**
+ * Continuation of one bounded poll (keeps its captured cut). Mutually
+ * exclusive with `cursor` and `start`.
+ */
+ page_token?: string;
+ limit?: number;
+ kind?: Array;
+ type?: Array;
+ op?: Array;
};
- query?: never;
- url: "/graphs/{graph_id}/branches/{branch}";
+ url: "/graphs/{graph_id}/changes";
};
-export type ClusterDeleteBranchErrors = {
+export type ClusterPollChangesErrors = {
+ /**
+ * Invalid start/filter combination, or a rejected cursor or page token
+ */
+ 400: ChangeErrorOutput;
/**
* Unauthorized
*/
- 401: ErrorOutput;
+ 401: ChangeErrorOutput;
/**
* Forbidden
*/
- 403: ErrorOutput;
+ 403: ChangeErrorOutput;
/**
* Branch not found
*/
- 404: ErrorOutput;
+ 404: ChangeErrorOutput;
/**
- * Per-actor admission cap exceeded; honor `Retry-After` header
+ * The feed crossed an unprovable schema boundary; see change_diff_refusal
*/
- 429: ErrorOutput;
+ 409: ChangeErrorOutput;
/**
- * An overlapping durable recovery intent must be resolved before retry
+ * Feed gap: required history was reclaimed; reset via the baseline handshake
*/
- 503: ErrorOutput;
+ 410: ChangeErrorOutput;
+ /**
+ * Requested limit exceeds the public change ceiling
+ */
+ 413: ChangeErrorOutput;
+ /**
+ * Internal failure while reading changes
+ */
+ 500: ChangeErrorOutput;
+ /**
+ * Recovery required before changes can be read
+ */
+ 503: ChangeErrorOutput;
};
-export type ClusterDeleteBranchError =
- ClusterDeleteBranchErrors[keyof ClusterDeleteBranchErrors];
+export type ClusterPollChangesError =
+ ClusterPollChangesErrors[keyof ClusterPollChangesErrors];
-export type ClusterDeleteBranchResponses = {
+export type ClusterPollChangesResponses = {
/**
- * Branch deleted
+ * Change blocks in first-parent order. The durable cursor appears only on a terminal page, advanced only over complete commits; a mid-block page carries only next_page_token
*/
- 200: BranchDeleteOutput;
+ 200: ChangeFeedOutput;
};
-export type ClusterDeleteBranchResponse =
- ClusterDeleteBranchResponses[keyof ClusterDeleteBranchResponses];
+export type ClusterPollChangesResponse =
+ ClusterPollChangesResponses[keyof ClusterPollChangesResponses];
-export type ClusterChangeData = {
- body: ChangeRequest;
+export type ClusterCaptureChangeBaselineData = {
+ body: ChangeBaselineRequest;
path: {
/**
* Graph id to route the request to.
@@ -826,51 +1500,52 @@ export type ClusterChangeData = {
graph_id: string;
};
query?: never;
- url: "/graphs/{graph_id}/change";
+ url: "/graphs/{graph_id}/changes/baseline";
};
-export type ClusterChangeErrors = {
+export type ClusterCaptureChangeBaselineErrors = {
/**
- * Bad request
+ * Invalid scope
*/
- 400: ErrorOutput;
+ 400: ChangeErrorOutput;
/**
* Unauthorized
*/
- 401: ErrorOutput;
+ 401: ChangeErrorOutput;
/**
* Forbidden
*/
- 403: ErrorOutput;
+ 403: ChangeErrorOutput;
/**
- * Write-authority conflict
+ * Branch not found
*/
- 409: ErrorOutput;
+ 404: ChangeErrorOutput;
/**
- * Keyed write exceeds the per-commit row or byte ceiling
+ * Baseline cut or transport capacity exhausted
*/
- 413: ErrorOutput;
+ 413: ChangeErrorOutput;
/**
- * Per-actor admission cap exceeded; honor `Retry-After` header
+ * Internal failure while capturing the baseline
*/
- 429: ErrorOutput;
+ 500: ChangeErrorOutput;
/**
- * An overlapping durable recovery intent must be resolved before retry
+ * Recovery required
*/
- 503: ErrorOutput;
+ 503: ChangeErrorOutput;
};
-export type ClusterChangeError = ClusterChangeErrors[keyof ClusterChangeErrors];
+export type ClusterCaptureChangeBaselineError =
+ ClusterCaptureChangeBaselineErrors[keyof ClusterCaptureChangeBaselineErrors];
-export type ClusterChangeResponses = {
+export type ClusterCaptureChangeBaselineResponses = {
/**
- * Mutation results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`)
+ * NDJSON entity snapshot pinned at one captured commit. Every preceding record is one type-keyed entity record (the load/export NDJSON shape); the FINAL record is the ChangeBaselineRecord envelope — an interrupted stream has no terminal record and therefore no usable cursor. Install the snapshot durably before the cursor.
*/
- 200: ChangeOutput;
+ 200: ChangeBaselineRecord;
};
-export type ClusterChangeResponse =
- ClusterChangeResponses[keyof ClusterChangeResponses];
+export type ClusterCaptureChangeBaselineResponse =
+ ClusterCaptureChangeBaselineResponses[keyof ClusterCaptureChangeBaselineResponses];
export type ClusterListCommitsData = {
body?: never;
@@ -954,6 +1629,92 @@ export type ClusterGetCommitResponses = {
export type ClusterGetCommitResponse =
ClusterGetCommitResponses[keyof ClusterGetCommitResponses];
+export type ClusterGetCommitChangesData = {
+ body?: never;
+ path: {
+ /**
+ * Graph id to route the request to.
+ */
+ graph_id: string;
+ /**
+ * Commit identifier
+ */
+ commit_id: string;
+ };
+ query?: {
+ /**
+ * Opaque continuation from the preceding page of this response.
+ */
+ page_token?: string;
+ /**
+ * Maximum changes per page. Server default applies when absent; above
+ * the public ceiling the request fails with 413.
+ */
+ limit?: number;
+ /**
+ * Repeatable filter: node | edge.
+ */
+ kind?: Array;
+ /**
+ * Repeatable filter: accepted-schema type name.
+ */
+ type?: Array;
+ /**
+ * Repeatable filter: insert | update | delete.
+ */
+ op?: Array;
+ };
+ url: "/graphs/{graph_id}/commits/{commit_id}/changes";
+};
+
+export type ClusterGetCommitChangesErrors = {
+ /**
+ * Invalid filter or limit, or a rejected page token
+ */
+ 400: ChangeErrorOutput;
+ /**
+ * Unauthorized
+ */
+ 401: ChangeErrorOutput;
+ /**
+ * Commit not found, or the actor cannot read the commit's branch
+ */
+ 404: ChangeErrorOutput;
+ /**
+ * Commit cannot be entity-diffed (parentless commit or schema boundary); see change_diff_refusal
+ */
+ 409: ChangeErrorOutput;
+ /**
+ * Required retained history is no longer readable; see change_feed_gap and capture a new baseline
+ */
+ 410: ChangeErrorOutput;
+ /**
+ * Requested limit exceeds the public change ceiling
+ */
+ 413: ChangeErrorOutput;
+ /**
+ * Internal failure while reading changes
+ */
+ 500: ChangeErrorOutput;
+ /**
+ * Recovery required before changes can be read
+ */
+ 503: ChangeErrorOutput;
+};
+
+export type ClusterGetCommitChangesError =
+ ClusterGetCommitChangesErrors[keyof ClusterGetCommitChangesErrors];
+
+export type ClusterGetCommitChangesResponses = {
+ /**
+ * Entity changes this commit made relative to its first parent, in frozen (kind, type, id, op) order with the cause stated once
+ */
+ 200: CommitChangesOutput;
+};
+
+export type ClusterGetCommitChangesResponse =
+ ClusterGetCommitChangesResponses[keyof ClusterGetCommitChangesResponses];
+
export type ClusterExportData = {
body: ExportRequest;
path: {
@@ -991,6 +1752,10 @@ export type ClusterExportErrors = {
* Export cut or transport capacity exhausted
*/
413: ErrorOutput;
+ /**
+ * Request body must use application/json
+ */
+ 415: ErrorOutput;
/**
* Recovery required
*/
@@ -1036,9 +1801,13 @@ export type ClusterIngestErrors = {
*/
409: ErrorOutput;
/**
- * Keyed load exceeds the per-commit row or byte ceiling
+ * Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling
*/
413: ErrorOutput;
+ /**
+ * An allowed external Blob source could not be probed or read
+ */
+ 424: ErrorOutput;
/**
* Per-actor admission cap exceeded; honor `Retry-After` header
*/
@@ -1091,9 +1860,13 @@ export type ClusterLoadErrors = {
*/
409: ErrorOutput;
/**
- * Keyed load exceeds the per-commit row or byte ceiling
+ * Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling
*/
413: ErrorOutput;
+ /**
+ * An allowed external Blob source could not be probed or read
+ */
+ 424: ErrorOutput;
/**
* Per-actor admission cap exceeded; honor `Retry-After` header
*/
@@ -1137,7 +1910,7 @@ export type ClusterLoadNdjsonData = {
*/
from?: string | null;
/**
- * How existing rows are handled. Defaults to `merge`.
+ * How existing entities are handled. Defaults to `merge`.
*/
mode?: null | LoadMode;
};
@@ -1166,13 +1939,17 @@ export type ClusterLoadNdjsonErrors = {
*/
409: ErrorOutput;
/**
- * Request or keyed load exceeds a bounded ceiling
+ * Request, load, or external Blob admission exceeds a bounded ceiling
*/
413: ErrorOutput;
/**
* Content-Type must be application/x-ndjson
*/
415: ErrorOutput;
+ /**
+ * An allowed external Blob source could not be probed or read
+ */
+ 424: ErrorOutput;
/**
* Per-actor admission cap exceeded; honor `Retry-After` header
*/
@@ -1226,9 +2003,13 @@ export type ClusterMutateErrors = {
*/
409: ErrorOutput;
/**
- * Keyed write exceeds the per-commit row or byte ceiling
+ * Keyed write exceeds the per-commit entity or byte ceiling
*/
413: ErrorOutput;
+ /**
+ * An allowed external Blob source could not be probed or read
+ */
+ 424: ErrorOutput;
/**
* Per-actor admission cap exceeded; honor `Retry-After` header
*/
@@ -1251,6 +2032,76 @@ export type ClusterMutateResponses = {
export type ClusterMutateResponse =
ClusterMutateResponses[keyof ClusterMutateResponses];
+export type ClusterMutateIfGraphCommitData = {
+ body: ChangeRequest;
+ headers: {
+ /**
+ * Required raw graph-head commit id. The mutation runs only while the branch's effective head still equals it.
+ */
+ "Omnigraph-If-Graph-Commit": string;
+ };
+ path: {
+ /**
+ * Graph id to route the request to.
+ */
+ graph_id: string;
+ };
+ query?: never;
+ url: "/graphs/{graph_id}/mutate/if-graph-commit";
+};
+
+export type ClusterMutateIfGraphCommitErrors = {
+ /**
+ * Missing, duplicate, malformed, or invalid request
+ */
+ 400: ErrorOutput;
+ /**
+ * Unauthorized
+ */
+ 401: ErrorOutput;
+ /**
+ * Forbidden
+ */
+ 403: ErrorOutput;
+ /**
+ * Write-authority conflict
+ */
+ 409: ErrorOutput;
+ /**
+ * Graph-commit precondition failed; the write had no effect
+ */
+ 412: ErrorOutput;
+ /**
+ * Keyed write exceeds the per-commit entity or byte ceiling
+ */
+ 413: ErrorOutput;
+ /**
+ * An allowed external Blob source could not be probed or read
+ */
+ 424: ErrorOutput;
+ /**
+ * Per-actor admission cap exceeded; honor `Retry-After` header
+ */
+ 429: ErrorOutput;
+ /**
+ * An overlapping durable recovery intent must be resolved before retry
+ */
+ 503: ErrorOutput;
+};
+
+export type ClusterMutateIfGraphCommitError =
+ ClusterMutateIfGraphCommitErrors[keyof ClusterMutateIfGraphCommitErrors];
+
+export type ClusterMutateIfGraphCommitResponses = {
+ /**
+ * Conditional mutation results
+ */
+ 200: ChangeOutput;
+};
+
+export type ClusterMutateIfGraphCommitResponse =
+ ClusterMutateIfGraphCommitResponses[keyof ClusterMutateIfGraphCommitResponses];
+
export type ClusterListQueriesData = {
body?: never;
path: {
@@ -1321,13 +2172,17 @@ export type ClusterInvokeQueryErrors = {
*/
404: ErrorOutput;
/**
- * Stored mutation write-authority conflict
+ * Stored mutation write-authority conflict, or a full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying
*/
409: ErrorOutput;
/**
- * Stored keyed mutation exceeds the per-commit row or byte ceiling
+ * Stored keyed mutation exceeds the per-commit entity or byte ceiling
*/
413: ErrorOutput;
+ /**
+ * A stored mutation could not probe or read an allowed external Blob source
+ */
+ 424: ErrorOutput;
/**
* Per-actor admission cap exceeded; honor `Retry-After` header
*/
@@ -1355,6 +2210,88 @@ export type ClusterInvokeQueryResponses = {
export type ClusterInvokeQueryResponse =
ClusterInvokeQueryResponses[keyof ClusterInvokeQueryResponses];
+export type ClusterInvokeQueryIfGraphCommitData = {
+ body?: null | InvokeStoredQueryRequest;
+ headers: {
+ /**
+ * Required raw graph-head commit id. The stored mutation runs only while the branch's effective head still equals it.
+ */
+ "Omnigraph-If-Graph-Commit": string;
+ };
+ path: {
+ /**
+ * Graph id to route the request to.
+ */
+ graph_id: string;
+ /**
+ * Stored mutation name (the registry key)
+ */
+ name: string;
+ };
+ query?: never;
+ url: "/graphs/{graph_id}/queries/{name}/if-graph-commit";
+};
+
+export type ClusterInvokeQueryIfGraphCommitErrors = {
+ /**
+ * Missing, duplicate, malformed, read-only, or invalid invocation
+ */
+ 400: ErrorOutput;
+ /**
+ * Unauthorized
+ */
+ 401: ErrorOutput;
+ /**
+ * Forbidden (the inner `change` gate)
+ */
+ 403: ErrorOutput;
+ /**
+ * Unknown stored mutation, or `invoke_query` denied
+ */
+ 404: ErrorOutput;
+ /**
+ * Stored mutation write-authority conflict
+ */
+ 409: ErrorOutput;
+ /**
+ * Stored mutation graph-commit precondition failed; the write had no effect
+ */
+ 412: ErrorOutput;
+ /**
+ * Stored keyed mutation exceeds the per-commit entity or byte ceiling
+ */
+ 413: ErrorOutput;
+ /**
+ * A stored mutation could not probe or read an allowed external Blob source
+ */
+ 424: ErrorOutput;
+ /**
+ * Per-actor admission cap exceeded; honor `Retry-After` header
+ */
+ 429: ErrorOutput;
+ /**
+ * Policy evaluation error (a denial is reported as 404, not 500)
+ */
+ 500: ErrorOutput;
+ /**
+ * A stored mutation is blocked by a durable recovery intent
+ */
+ 503: ErrorOutput;
+};
+
+export type ClusterInvokeQueryIfGraphCommitError =
+ ClusterInvokeQueryIfGraphCommitErrors[keyof ClusterInvokeQueryIfGraphCommitErrors];
+
+export type ClusterInvokeQueryIfGraphCommitResponses = {
+ /**
+ * Stored conditional mutation result
+ */
+ 200: ChangeOutput;
+};
+
+export type ClusterInvokeQueryIfGraphCommitResponse =
+ ClusterInvokeQueryIfGraphCommitResponses[keyof ClusterInvokeQueryIfGraphCommitResponses];
+
export type ClusterQueryData = {
body: QueryRequest;
path: {
@@ -1380,6 +2317,10 @@ export type ClusterQueryErrors = {
* Forbidden
*/
403: ErrorOutput;
+ /**
+ * Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying
+ */
+ 409: ErrorOutput;
};
export type ClusterQueryError = ClusterQueryErrors[keyof ClusterQueryErrors];
@@ -1419,15 +2360,19 @@ export type ClusterReadErrors = {
* Forbidden
*/
403: ErrorOutput;
+ /**
+ * Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying
+ */
+ 409: ErrorOutput;
};
export type ClusterReadError = ClusterReadErrors[keyof ClusterReadErrors];
export type ClusterReadResponses = {
/**
- * Query results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`)
+ * Legacy token-free query results (response includes `Deprecation: true` + `Link: ; rel="successor-version"`)
*/
- 200: ReadOutput;
+ 200: LegacyReadOutput;
};
export type ClusterReadResponse =
@@ -1547,7 +2492,7 @@ export type ClusterGetSnapshotError =
export type ClusterGetSnapshotResponses = {
/**
- * Database snapshot
+ * Graph snapshot
*/
200: SnapshotOutput;
};
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index b778018..4ddb0e5 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -4,7 +4,9 @@ export default Omnigraph;
export { Omnigraph };
export type { OmnigraphOptions, SnapshotInput } from './client';
-export type { CallOptions, ListCommitsInput, FetchLike } from './internals';
+export type { CallOptions, ConditionalCallOptions, ListCommitsInput, FetchLike } from './internals';
+export type { BlobInput } from './resources/blobs';
+export type { PollChangesInput, ChangePageInput } from './resources/changes';
// Build-time pin: which omnigraph-server release this SDK was generated
// against. Compare against `og.health()` at startup if you want to detect
@@ -20,6 +22,12 @@ export {
NotFoundError,
MethodNotAllowedError,
ConflictError,
+ GoneError,
+ PreconditionFailedError,
+ PayloadTooLargeError,
+ RangeNotSatisfiableError,
+ FailedDependencyError,
+ ServiceUnavailableError,
TooManyRequestsError,
InternalServerError,
NetworkError,
@@ -54,13 +62,12 @@ export type {
GraphBatchLoad,
Ingest,
IngestInput,
- IngestTable,
LoadNdjsonInput,
QueryInput,
Read,
ReadTarget,
Snapshot,
- SnapshotTable,
+ SnapshotDataset,
// Stored queries
Queries,
QueryCatalogEntry,
@@ -69,8 +76,18 @@ export type {
InvokeQueryInput,
// Conflict / errors / shared
MergeConflict,
- ManifestConflict,
+ PublishedDatasetVersionConflict,
ErrorOutput,
+ CommitChanges,
+ ChangeFeed,
+ ChangeBlock,
+ ChangeCause,
+ EntityChange,
+ ChangeImage,
+ ChangeBaseline,
+ ChangeBaselineInput,
+ ChangeBaselineRecord,
+ ExportRecord,
// Utility
Camelize,
} from './types';
@@ -84,4 +101,8 @@ export {
LoadMode,
MergeConflictKindOutput,
ParamKind,
+ BlobEntityKind,
+ EntityKindOutput,
+ ChangeOpOutput,
+ ChangeDiffRefusalReason,
} from './types';
diff --git a/packages/sdk/src/internals.ts b/packages/sdk/src/internals.ts
index bd6412b..298488d 100644
--- a/packages/sdk/src/internals.ts
+++ b/packages/sdk/src/internals.ts
@@ -6,6 +6,12 @@ export interface CallOptions {
signal?: AbortSignal;
}
+/** Mutation-only options. The conditional route is never downgraded on failure. */
+export interface ConditionalCallOptions extends CallOptions {
+ /** Exact graphCommitId from the read that informed this mutation. */
+ ifGraphCommit?: string;
+}
+
// Stable re-exports for consumer types that don't fit elsewhere.
export type { ListCommitsInput } from './resources/commits';
export type { FetchLike } from './transport';
diff --git a/packages/sdk/src/resources/blobs.ts b/packages/sdk/src/resources/blobs.ts
new file mode 100644
index 0000000..37de7cc
--- /dev/null
+++ b/packages/sdk/src/resources/blobs.ts
@@ -0,0 +1,89 @@
+import type { CallOptions } from '../internals';
+import type { Transport } from '../transport';
+import type { BlobEntityKind } from '../types';
+
+/** Select one Blob cell through logical graph identity, never a storage path. */
+export interface BlobInput {
+ entity: BlobEntityKind;
+ type: string;
+ id: string;
+ property: string;
+ /** Defaults to main. Mutually exclusive with snapshot. */
+ branch?: string;
+ /** Graph commit id (e.g. query.graphCommitId), not the opaque response header. Exclusive with branch. */
+ snapshot?: string;
+ /** Strong entity-tag-list precondition (including `*`). */
+ ifMatch?: string;
+ /** Weak entity-tag-list comparison (including `*`). */
+ ifNoneMatch?: string;
+ /** One standard bytes range. Ignored by stat/HEAD. */
+ range?: string;
+ /** Strong entity tag; a mismatch serves the full body. Ignored by stat. */
+ ifRange?: string;
+}
+
+function blobHeaders(input: BlobInput): Record {
+ const headers: Record = {
+ Accept: 'application/octet-stream',
+ };
+ if (input.ifMatch !== undefined) headers['If-Match'] = input.ifMatch;
+ if (input.ifNoneMatch !== undefined)
+ headers['If-None-Match'] = input.ifNoneMatch;
+ if (input.range !== undefined) headers.Range = input.range;
+ if (input.ifRange !== undefined) headers['If-Range'] = input.ifRange;
+ return headers;
+}
+
+/**
+ * Read-only Blob delivery. Write Blob values using normal mutate/load calls.
+ * Responses retain status, headers, and the byte stream without JSON parsing.
+ * ETags are opaque validators, not content hashes.
+ */
+export class BlobsResource {
+ constructor(private readonly t: Transport) {}
+
+ /**
+ * GET managed bytes (200/206), an external Location (302), or not-modified
+ * metadata (304). Redirects are never followed: the external URI is not
+ * authorized or proxied by Omnigraph. Inspect status before consuming bytes.
+ * A 412/416 is an error; its response headers remain available on the error.
+ */
+ get(input: BlobInput, opts: CallOptions = {}): Promise {
+ return this.t.stream('GET', '/blob', {
+ query: {
+ entity: input.entity,
+ type: input.type,
+ id: input.id,
+ property: input.property,
+ branch: input.branch,
+ snapshot: input.snapshot,
+ },
+ headers: blobHeaders(input),
+ redirect: 'manual',
+ acceptedStatuses: [302, 304],
+ signal: opts.signal,
+ });
+ }
+
+ /**
+ * HEAD metadata without payload bytes. Range/If-Range are ignored by the
+ * server. A 302 exposes the external Location without following it; a 304
+ * indicates the validator matched. HEAD error responses have no JSON body.
+ */
+ stat(input: BlobInput, opts: CallOptions = {}): Promise {
+ return this.t.stream('HEAD', '/blob', {
+ query: {
+ entity: input.entity,
+ type: input.type,
+ id: input.id,
+ property: input.property,
+ branch: input.branch,
+ snapshot: input.snapshot,
+ },
+ headers: blobHeaders(input),
+ redirect: 'manual',
+ acceptedStatuses: [302, 304],
+ signal: opts.signal,
+ });
+ }
+}
diff --git a/packages/sdk/src/resources/changes.ts b/packages/sdk/src/resources/changes.ts
new file mode 100644
index 0000000..2725c14
--- /dev/null
+++ b/packages/sdk/src/resources/changes.ts
@@ -0,0 +1,116 @@
+import type { CallOptions } from '../internals';
+import { ndjsonIterator } from '../stream';
+import type { Transport } from '../transport';
+import type {
+ ChangeBaselineInput,
+ ChangeBaselineRecord,
+ ChangeFeed,
+ ChangeOpOutput,
+ EntityKindOutput,
+} from '../types';
+
+/** Filters and continuation for one bounded page of logical entity changes. */
+export interface ChangePageInput {
+ /** Opaque continuation for this result, not a durable feed cursor. */
+ pageToken?: string;
+ /** Maximum changes in this page. Server default and ceiling apply. */
+ limit?: number;
+ kind?: EntityKindOutput[];
+ /** Accepted-schema type names. */
+ type?: string[];
+ op?: ChangeOpOutput[];
+}
+
+export interface PollChangesInput extends ChangePageInput {
+ /** Branch to follow. Defaults to `main`. */
+ branch?: string;
+ /** Durable cursor from a terminal page; exclusive with start/pageToken. */
+ cursor?: string;
+ /** Defaults to `now`; exclusive with cursor/pageToken. */
+ start?: 'now' | 'beginning' | `after:${string}`;
+}
+
+const OPAQUE_PROPERTIES = new Set(['properties']);
+const OPAQUE_BASELINE_DATA = new Set(['data']);
+
+export class ChangesResource {
+ constructor(private readonly t: Transport) {}
+
+ /**
+ * Read one bounded feed page. Follow nextPageToken within the captured
+ * poll; persist cursor only from its terminal page, atomically with the
+ * applied blocks. Delivery is at least once. A 410 requires a new baseline.
+ */
+ poll(
+ input: PollChangesInput = {},
+ opts: CallOptions = {},
+ ): Promise {
+ return this.t.request('GET', '/changes', {
+ query: {
+ branch: input.branch,
+ cursor: input.cursor,
+ start: input.start,
+ page_token: input.pageToken,
+ limit: input.limit?.toString(),
+ kind: input.kind,
+ type: input.type,
+ op: input.op,
+ },
+ signal: opts.signal,
+ opaqueResponseKeys: OPAQUE_PROPERTIES,
+ });
+ }
+
+ /**
+ * Stream a pinned node/edge snapshot followed by one final { baseline }
+ * record. Entity records use the load/export NDJSON shape; data is opaque.
+ * The terminal record is not an entity. An interrupted stream has no usable
+ * cursor: install the complete snapshot durably before saving resumeCursor.
+ * kind/type filter the snapshot; op filters only the subsequent feed.
+ *
+ * Iterate once; the request starts lazily. Breaking or aborting cancels it.
+ */
+ baseline(
+ input: ChangeBaselineInput = {},
+ opts: CallOptions = {},
+ ): AsyncIterable {
+ const t = this.t;
+ return {
+ async *[Symbol.asyncIterator]() {
+ const response = await t.stream('POST', '/changes/baseline', {
+ body: input,
+ signal: opts.signal,
+ });
+ let terminal: ChangeBaselineRecord | undefined;
+ for await (const record of ndjsonIterator(
+ response,
+ {
+ opaqueKeys: OPAQUE_BASELINE_DATA,
+ },
+ )) {
+ if (terminal !== undefined) {
+ throw new Error(
+ 'Change baseline has records after its terminal record',
+ );
+ }
+ if (record && typeof record === 'object' && 'baseline' in record) {
+ if (
+ typeof record.baseline?.snapshotCommitId !== 'string' ||
+ typeof record.baseline?.resumeCursor !== 'string'
+ ) {
+ throw new Error('Change baseline has an invalid terminal record');
+ }
+ terminal = record;
+ } else {
+ yield record;
+ }
+ }
+ if (terminal === undefined) {
+ throw new Error('Change baseline ended without a terminal record');
+ }
+ // Do not expose the resume cursor until the stream ended successfully.
+ yield terminal;
+ },
+ };
+ }
+}
diff --git a/packages/sdk/src/resources/commits.ts b/packages/sdk/src/resources/commits.ts
index 767b2e8..77276fc 100644
--- a/packages/sdk/src/resources/commits.ts
+++ b/packages/sdk/src/resources/commits.ts
@@ -1,6 +1,7 @@
import type { Transport } from '../transport';
-import type { Commit, CommitList } from '../types';
+import type { Commit, CommitChanges, CommitList } from '../types';
import type { CallOptions } from '../internals';
+import type { ChangePageInput } from './changes';
export interface ListCommitsInput {
branch?: string;
@@ -31,4 +32,31 @@ export class CommitsResource {
{ signal: opts.signal },
);
}
+
+ /**
+ * Read one page of logical entity changes relative to the first parent.
+ * The page token continues this commit result; it is not a feed cursor.
+ * A parentless commit or schema boundary is refused, never an empty diff.
+ */
+ changes(
+ id: string,
+ input: ChangePageInput = {},
+ opts: CallOptions = {},
+ ): Promise {
+ return this.t.request(
+ 'GET',
+ `/commits/${encodeURIComponent(id)}/changes`,
+ {
+ query: {
+ page_token: input.pageToken,
+ limit: input.limit?.toString(),
+ kind: input.kind,
+ type: input.type,
+ op: input.op,
+ },
+ signal: opts.signal,
+ opaqueResponseKeys: new Set(['properties']),
+ },
+ );
+ }
}
diff --git a/packages/sdk/src/resources/queries.ts b/packages/sdk/src/resources/queries.ts
index 072d9ab..5df3092 100644
--- a/packages/sdk/src/resources/queries.ts
+++ b/packages/sdk/src/resources/queries.ts
@@ -1,6 +1,6 @@
import type { Transport } from '../transport';
import type { InvokeQuery, InvokeQueryInput, Queries } from '../types';
-import type { CallOptions } from '../internals';
+import type { CallOptions, ConditionalCallOptions } from '../internals';
// Mirror client.ts: `params` keys are caller-controlled (matched by name to
// `$var` in the stored query) and a stored *read* returns opaque
@@ -37,12 +37,26 @@ export class QueriesResource {
* 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.
+ * `opts.ifGraphCommit` selects the dedicated conditional mutation route;
+ * a stored read is rejected there and an unsupported route never falls back.
*/
invoke(
name: string,
input: InvokeQueryInput = {},
- opts: CallOptions = {},
+ opts: ConditionalCallOptions = {},
): Promise {
+ if (opts.ifGraphCommit !== undefined) {
+ return this.t.request(
+ 'POST',
+ `/queries/${encodeURIComponent(name)}/if-graph-commit`,
+ {
+ body: input,
+ headers: { 'Omnigraph-If-Graph-Commit': opts.ifGraphCommit },
+ signal: opts.signal,
+ opaqueBodyKeys: OPAQUE_PARAMS,
+ },
+ );
+ }
return this.t.request(
'POST',
`/queries/${encodeURIComponent(name)}`,
diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts
index 1928778..3857622 100644
--- a/packages/sdk/src/transport.ts
+++ b/packages/sdk/src/transport.ts
@@ -29,6 +29,11 @@ export interface TransportOptions {
const FLAT_PATHS: ReadonlySet = new Set(['/healthz', '/graphs']);
export interface RequestOptions {
+ headers?: Record;
+ /** Blob descriptors must not be automatically followed to external origins. */
+ redirect?: RequestRedirect;
+ /** Explicit non-2xx successes (Blob 302 descriptors and 304 cache hits). */
+ acceptedStatuses?: readonly number[];
body?: unknown;
/**
* Raw request body sent verbatim with the given Content-Type, bypassing
@@ -115,9 +120,9 @@ export class Transport {
});
}
const url = this.buildUrl(path, opts.query);
- const headers = new Headers();
+ const headers = new Headers(opts.headers);
if (this.token) headers.set('Authorization', `Bearer ${this.token}`);
- headers.set('Accept', 'application/json, application/x-ndjson');
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json, application/x-ndjson');
let bodyInit: BodyInit | undefined;
if (opts.rawBody !== undefined) {
bodyInit = opts.rawBody.content;
@@ -133,6 +138,7 @@ export class Transport {
headers,
body: bodyInit,
signal: opts.signal,
+ redirect: opts.redirect,
};
const requestMeta = { method, url };
let response: Response;
@@ -146,7 +152,7 @@ export class Transport {
request: requestMeta,
});
}
- if (!response.ok) {
+ if (!response.ok && !opts.acceptedStatuses?.includes(response.status)) {
const requestId = response.headers.get('X-Request-Id') ?? undefined;
const text = await response.text().catch(() => '');
let body: unknown;
diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts
index 325ad71..25e9943 100644
--- a/packages/sdk/src/types.ts
+++ b/packages/sdk/src/types.ts
@@ -25,11 +25,10 @@ import type {
HealthOutput,
IngestOutput,
IngestRequest,
- IngestTableOutput,
InvokeStoredQueryRequest,
InvokeStoredQueryResponse,
LoadMode,
- ManifestConflictOutput,
+ PublishedDatasetVersionConflictOutput,
MergeConflictKindOutput,
MergeConflictOutput,
ParamDescriptor as ParamDescriptorOutput,
@@ -42,18 +41,19 @@ import type {
SchemaApplyRequest,
SchemaOutput,
SnapshotOutput,
- SnapshotTableOutput,
+ SnapshotDatasetOutput,
} from './generated/types.gen';
type CamelKey = S extends `${infer P}_${infer R}`
? `${P}${Capitalize>}`
: S;
-export type Camelize = T extends Array
- ? Array>
- : T extends object
- ? { [K in keyof T as K extends string ? CamelKey : K]: Camelize }
- : T;
+export type Camelize =
+ T extends Array
+ ? Array>
+ : T extends object
+ ? { [K in keyof T as K extends string ? CamelKey : K]: Camelize }
+ : T;
// Outputs (responses): camelCase facing the caller.
export type BranchCreate = Camelize;
@@ -67,7 +67,6 @@ export type GraphInfo = Camelize;
export type GraphList = Camelize;
export type Health = Camelize;
export type Ingest = Camelize;
-export type IngestTable = Camelize;
// Strict graph-level NDJSON batch load (POST /load/ndjson).
export type GraphBatchLoad = Camelize;
export type GraphBatchDeclaration = Camelize;
@@ -83,9 +82,43 @@ export type InvokeQuery = Camelize;
export type Schema = Camelize;
export type SchemaApply = Camelize;
export type Snapshot = Camelize;
-export type SnapshotTable = Camelize;
+export type SnapshotDataset = Camelize;
export type MergeConflict = Camelize;
-export type ManifestConflict = Camelize;
+export type PublishedDatasetVersionConflict =
+ Camelize;
+
+export type CommitChanges = Camelize<
+ import('./generated/types.gen').CommitChangesOutput
+>;
+export type ChangeFeed = Camelize<
+ import('./generated/types.gen').ChangeFeedOutput
+>;
+export type ChangeBlock = Camelize<
+ import('./generated/types.gen').ChangeBlockOutput
+>;
+export type ChangeCause = Camelize<
+ import('./generated/types.gen').ChangeCauseOutput
+>;
+export type EntityChange = Camelize<
+ import('./generated/types.gen').EntityChangeOutput
+>;
+export type ChangeImage = Camelize<
+ import('./generated/types.gen').ChangeImageOutput
+>;
+export type ChangeBaseline = Camelize<
+ import('./generated/types.gen').ChangeBaselineOutput
+>;
+export type ChangeBaselineInput = Camelize<
+ import('./generated/types.gen').ChangeBaselineRequest
+>;
+/** Entity NDJSON envelopes; user property names inside data are never converted. */
+export type ExportRecord =
+ | { type: string; data: Record }
+ | { edge: string; from: string; to: string; data: Record };
+/** The OpenAPI record describes the terminal envelope only; entities precede it. */
+export type ChangeBaselineRecord =
+ | ExportRecord
+ | Camelize;
// Inputs (requests): camelCase from the caller, converted to snake_case on the wire.
export type BranchCreateInput = Camelize;
@@ -125,6 +158,10 @@ export {
LoadMode,
MergeConflictKindOutput,
ParamKind,
+ BlobEntityKind,
+ EntityKindOutput,
+ ChangeOpOutput,
+ ChangeDiffRefusalReason,
} from './generated/types.gen';
// CamelErrorOutput is the camelCased version surfaced on OmnigraphError.body.
diff --git a/packages/sdk/src/version.gen.ts b/packages/sdk/src/version.gen.ts
index f82e01e..1178a1c 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.9.0";
+export const SERVER_VERSION = "0.10.0";
diff --git a/packages/sdk/test/blobs.test.ts b/packages/sdk/test/blobs.test.ts
new file mode 100644
index 0000000..0b1ef3e
--- /dev/null
+++ b/packages/sdk/test/blobs.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it, vi } from 'vitest';
+import Omnigraph, { NotFoundError } from '../src';
+import { stubFetch } from './helpers';
+
+const cell = {
+ entity: 'node' as const,
+ type: 'Document',
+ id: 'a/b',
+ property: 'file_data',
+};
+
+describe('blobs resource', () => {
+ it('get preserves raw binary and metadata, forwards selectors/headers, and disables redirects', async () => {
+ const bytes = new Uint8Array([0, 255, 128, 65]);
+ const response = new Response(bytes, {
+ status: 206,
+ headers: {
+ 'Content-Type': 'application/octet-stream',
+ 'Content-Length': '4',
+ 'Content-Range': 'bytes 2-5/10',
+ ETag: '"opaque"',
+ 'Omnigraph-Snapshot-Id': 'c',
+ },
+ });
+ const fetch = vi.fn(
+ async (_url: string | URL | Request, _init?: RequestInit) => response,
+ );
+ const og = new Omnigraph({
+ baseUrl: 'http://x',
+ graphId: 'g',
+ token: 'token',
+ fetch,
+ });
+ const signal = new AbortController().signal;
+ const result = await og.blobs.get(
+ {
+ ...cell,
+ branch: 'feature/a',
+ range: 'bytes=2-5',
+ ifMatch: '"opaque"',
+ ifNoneMatch: '"old"',
+ ifRange: '"opaque"',
+ },
+ { signal },
+ );
+ expect(result).toBe(response);
+ expect(new Uint8Array(await result.arrayBuffer())).toEqual(bytes);
+ expect(result.headers.get('Omnigraph-Snapshot-Id')).toBe('c');
+ const [requestUrl, init] = fetch.mock.calls[0]!;
+ const url = new URL(String(requestUrl));
+ expect(url.pathname).toBe('/graphs/g/blob');
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ ...cell,
+ branch: 'feature/a',
+ });
+ expect(init?.method).toBe('GET');
+ expect(init?.redirect).toBe('manual');
+ expect(init?.signal).toBe(signal);
+ const headers = new Headers(init?.headers);
+ expect(headers.get('Authorization')).toBe('Bearer token');
+ expect(headers.get('Accept')).toBe('application/octet-stream');
+ expect(headers.get('Range')).toBe('bytes=2-5');
+ expect(headers.get('If-Match')).toBe('"opaque"');
+ expect(headers.get('If-None-Match')).toBe('"old"');
+ expect(headers.get('If-Range')).toBe('"opaque"');
+ });
+
+ it.each(['get', 'stat'] as const)(
+ '%s exposes external redirects without following them',
+ async (method) => {
+ const response = new Response(null, {
+ status: 302,
+ headers: {
+ Location: 'https://external.example/private-object',
+ 'Cache-Control': 'no-store',
+ },
+ });
+ const fetch = vi.fn(
+ async (_url: string | URL | Request, _init?: RequestInit) => response,
+ );
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const result = await og.blobs[method](cell);
+ expect(result.status).toBe(302);
+ expect(result.headers.get('Location')).toBe(
+ 'https://external.example/private-object',
+ );
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(fetch.mock.calls[0]?.[1]?.redirect).toBe('manual');
+ },
+ );
+
+ it.each(['get', 'stat'] as const)(
+ '%s returns a bodyless 304 without trying to decode JSON',
+ async (method) => {
+ const { fetch } = stubFetch({
+ status: 304,
+ headers: { ETag: '"unchanged"' },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const result = await og.blobs[method]({
+ ...cell,
+ ifNoneMatch: '"unchanged"',
+ });
+ expect(result.status).toBe(304);
+ expect(result.body).toBeNull();
+ expect(result.headers.get('etag')).toBe('"unchanged"');
+ },
+ );
+
+ it('stat uses HEAD against a snapshot and retains complete representation metadata', async () => {
+ const response = new Response(null, {
+ headers: {
+ 'Content-Length': '100',
+ ETag: '"value"',
+ 'Omnigraph-Snapshot-Id': 'snapshot',
+ },
+ });
+ const fetch = vi.fn(
+ async (_url: string | URL | Request, _init?: RequestInit) => response,
+ );
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const result = await og.blobs.stat({
+ ...cell,
+ entity: 'edge',
+ snapshot: 'snapshot',
+ });
+ expect(result.body).toBeNull();
+ expect(result.headers.get('Content-Length')).toBe('100');
+ expect(fetch.mock.calls[0]?.[1]?.method).toBe('HEAD');
+ expect(
+ new URL(String(fetch.mock.calls[0]?.[0])).searchParams.get('snapshot'),
+ ).toBe('snapshot');
+ expect(
+ new URL(String(fetch.mock.calls[0]?.[0])).searchParams.has('branch'),
+ ).toBe(false);
+ });
+
+ it('preserves headers and structured details for an unsatisfiable range', async () => {
+ const { fetch } = stubFetch({
+ status: 416,
+ body: {
+ error: 'Range unsatisfiable',
+ blob_range: { start: 10, end: 11, length: 5 },
+ },
+ headers: { 'Content-Range': 'bytes */5', ETag: '"value"' },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(
+ og.blobs.get({ ...cell, range: 'bytes=10-10' }),
+ ).rejects.toMatchObject({
+ status: 416,
+ body: { blobRange: { start: 10, end: 11, length: 5 } },
+ response: expect.objectContaining({ status: 416 }),
+ });
+ });
+
+ it('maps a bodyless HEAD 404 to the correct status error', async () => {
+ const fetch = async () =>
+ new Response(null, { status: 404, headers: { 'X-Request-Id': 'r' } });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(og.blobs.stat(cell)).rejects.toBeInstanceOf(NotFoundError);
+ await expect(og.blobs.stat(cell)).rejects.toMatchObject({
+ status: 404,
+ requestId: 'r',
+ message: 'HTTP 404',
+ });
+ });
+});
diff --git a/packages/sdk/test/case.test.ts b/packages/sdk/test/case.test.ts
index f5a06d1..75c6b42 100644
--- a/packages/sdk/test/case.test.ts
+++ b/packages/sdk/test/case.test.ts
@@ -7,7 +7,7 @@ describe('case boundary', () => {
graph_commit_id: '01KQ',
created_at: 1,
merge_conflicts: [
- { table_key: 't', row_id: 'r', kind: 'divergent_insert' },
+ { type_name: 'Person', entity_id: 'r', kind: 'divergent_insert' },
],
empty: null,
};
@@ -15,7 +15,7 @@ describe('case boundary', () => {
expect(camel).toEqual({
graphCommitId: '01KQ',
createdAt: 1,
- mergeConflicts: [{ tableKey: 't', rowId: 'r', kind: 'divergent_insert' }],
+ mergeConflicts: [{ typeName: 'Person', entityId: 'r', kind: 'divergent_insert' }],
empty: null,
});
});
@@ -38,7 +38,7 @@ describe('case boundary', () => {
});
it('snakeToCamel handles arrays of primitives', () => {
- const out = snakeToCamel({ table_keys: ['a', 'b', 'c'] });
- expect(out).toEqual({ tableKeys: ['a', 'b', 'c'] });
+ const out = snakeToCamel({ type_names: ['a', 'b', 'c'] });
+ expect(out).toEqual({ typeNames: ['a', 'b', 'c'] });
});
});
diff --git a/packages/sdk/test/changes.test.ts b/packages/sdk/test/changes.test.ts
new file mode 100644
index 0000000..0b5a8d5
--- /dev/null
+++ b/packages/sdk/test/changes.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it } from 'vitest';
+import Omnigraph, { ConfigurationError } from '../src';
+import { stubFetch } from './helpers';
+
+describe('changes resource', () => {
+ it('poll sends the full repeated filter scope and preserves image property keys', async () => {
+ const properties = {
+ first_name: 'Ada',
+ nested_value: { raw_key: 1, camelKey: 2 },
+ };
+ const { fetch, calls } = stubFetch({
+ body: {
+ blocks: [
+ {
+ cause: {
+ graph_commit_id: 'c',
+ authored_branch: 'feature',
+ authored_at: 123,
+ },
+ changes: [
+ {
+ kind: 'node',
+ type: { id: 'type-id', name: 'Person' },
+ id: 'p',
+ op: 'insert',
+ after: { properties },
+ },
+ ],
+ },
+ ],
+ next_page_token: 'continuation',
+ },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const result = await og.changes.poll({
+ branch: 'feature/a',
+ cursor: 'cursor+/=',
+ limit: 12,
+ kind: ['node', 'edge'],
+ type: ['Person', 'Knows'],
+ op: ['insert', 'delete'],
+ });
+ expect(calls[0]?.method).toBe('GET');
+ const url = new URL(calls[0]!.url);
+ expect(url.pathname).toBe('/graphs/g/changes');
+ expect(url.searchParams.get('branch')).toBe('feature/a');
+ expect(url.searchParams.get('cursor')).toBe('cursor+/=');
+ expect(url.searchParams.get('limit')).toBe('12');
+ expect(url.searchParams.getAll('kind')).toEqual(['node', 'edge']);
+ expect(url.searchParams.getAll('type')).toEqual(['Person', 'Knows']);
+ expect(url.searchParams.getAll('op')).toEqual(['insert', 'delete']);
+ expect(result.blocks[0]?.cause.graphCommitId).toBe('c');
+ expect(result.blocks[0]?.changes[0]?.after?.properties).toEqual(properties);
+ expect(result.nextPageToken).toBe('continuation');
+ expect(result.cursor).toBeUndefined();
+ });
+
+ it('poll supports start and page-token requests without inventing a durable cursor', async () => {
+ const { fetch, calls } = stubFetch([
+ { body: { blocks: [], next_page_token: 'page' } },
+ { body: { blocks: [], cursor: 'durable', caught_up: true } },
+ { body: { blocks: [], cursor: 'now', caught_up: true } },
+ ]);
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const first = await og.changes.poll({ start: 'after:c' });
+ const last = await og.changes.poll({ pageToken: first.nextPageToken! });
+ await og.changes.poll();
+ expect(calls[0]?.url).toBe('http://x/graphs/g/changes?start=after%3Ac');
+ expect(calls[1]?.url).toBe('http://x/graphs/g/changes?page_token=page');
+ expect(calls[2]?.url).toBe('http://x/graphs/g/changes');
+ expect(first.cursor).toBeUndefined();
+ expect(last.cursor).toBe('durable');
+ expect(last.caughtUp).toBe(true);
+ });
+
+ it('poll surfaces retention gaps and never retries them', async () => {
+ const { fetch, calls } = stubFetch({
+ status: 410,
+ body: {
+ error: 'Required history was reclaimed',
+ change_feed_gap: { first_unreadable_commit_id: 'old', cursor: 'saved' },
+ },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(og.changes.poll({ cursor: 'saved' })).rejects.toMatchObject({
+ status: 410,
+ body: {
+ changeFeedGap: { firstUnreadableCommitId: 'old', cursor: 'saved' },
+ },
+ });
+ expect(calls).toHaveLength(1);
+ });
+
+ it('requires a graph before issuing a feed request', async () => {
+ const { fetch, calls } = stubFetch({ body: {} });
+ const og = new Omnigraph({ baseUrl: 'http://x', fetch });
+ await expect(og.changes.poll()).rejects.toBeInstanceOf(ConfigurationError);
+ expect(calls).toHaveLength(0);
+ });
+});
+
+describe('change baseline stream', () => {
+ it('streams nodes and edges with opaque data before the typed terminal handshake', async () => {
+ const records = [
+ {
+ type: 'Person',
+ data: { first_name: 'Ada', nested_value: { raw_key: 1 } },
+ },
+ { edge: 'Knows', from: 'a', to: 'b', data: { since_year: 2020 } },
+ { baseline: { snapshot_commit_id: 'c', resume_cursor: 'resume' } },
+ ];
+ const { fetch, calls } = stubFetch({
+ body: records.map((r) => JSON.stringify(r)).join('\n'),
+ headers: { 'content-type': 'application/x-ndjson' },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const stream = og.changes.baseline({
+ branch: 'feature',
+ kind: ['node', 'edge'],
+ type: ['Person', 'Knows'],
+ op: ['update'],
+ });
+ expect(calls).toHaveLength(0);
+ const actual = [];
+ for await (const record of stream) actual.push(record);
+ expect(calls[0]?.method).toBe('POST');
+ expect(calls[0]?.url).toBe('http://x/graphs/g/changes/baseline');
+ expect(JSON.parse(calls[0]!.body!)).toEqual({
+ branch: 'feature',
+ kind: ['node', 'edge'],
+ type: ['Person', 'Knows'],
+ op: ['update'],
+ });
+ expect(actual).toEqual([
+ records[0],
+ records[1],
+ { baseline: { snapshotCommitId: 'c', resumeCursor: 'resume' } },
+ ]);
+ });
+
+ it.each([
+ [
+ 'missing terminal',
+ '{"type":"Person","data":{}}\n',
+ 'ended without a terminal',
+ ],
+ [
+ 'invalid terminal',
+ '{"baseline":{"snapshot_commit_id":"c"}}\n',
+ 'invalid terminal',
+ ],
+ [
+ 'non-final terminal',
+ '{"baseline":{"snapshot_commit_id":"c","resume_cursor":"r"}}\n{"type":"Person","data":{}}\n',
+ 'after its terminal',
+ ],
+ [
+ 'duplicate terminal',
+ '{"baseline":{"snapshot_commit_id":"c","resume_cursor":"r"}}\n{"baseline":{"snapshot_commit_id":"c","resume_cursor":"r"}}\n',
+ 'after its terminal',
+ ],
+ ])(
+ 'rejects %s without yielding a usable cursor',
+ async (_name, body, error) => {
+ const { fetch } = stubFetch({
+ body,
+ headers: { 'content-type': 'application/x-ndjson' },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const seen: unknown[] = [];
+ const consume = async () => {
+ for await (const record of og.changes.baseline()) seen.push(record);
+ };
+ await expect(consume()).rejects.toThrow(error);
+ expect(seen.every((record) => !('baseline' in (record as object)))).toBe(
+ true,
+ );
+ },
+ );
+
+ it('does not yield the cursor if transport fails after the terminal bytes arrive', async () => {
+ const fetch = async () =>
+ new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ new TextEncoder().encode(
+ '{"baseline":{"snapshot_commit_id":"c","resume_cursor":"r"}}\n',
+ ),
+ );
+ },
+ pull(controller) {
+ controller.error(new Error('transfer interrupted'));
+ },
+ }),
+ );
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const seen: unknown[] = [];
+ const consume = async () => {
+ for await (const record of og.changes.baseline()) seen.push(record);
+ };
+ await expect(consume()).rejects.toThrow('transfer interrupted');
+ expect(seen).toEqual([]);
+ });
+
+ it('cancels the response when the caller stops before completing the snapshot', async () => {
+ let cancelled = false;
+ const ac = new AbortController();
+ const fetch = async (_url: string | URL | Request, init?: RequestInit) => {
+ expect(init?.signal).toBe(ac.signal);
+ expect(init?.body).toBe('{}');
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(
+ new TextEncoder().encode('{"type":"Person","data":{}}\n'),
+ );
+ },
+ cancel() {
+ cancelled = true;
+ },
+ }),
+ );
+ };
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ for await (const _record of og.changes.baseline({}, { signal: ac.signal }))
+ break;
+ expect(cancelled).toBe(true);
+ });
+});
diff --git a/packages/sdk/test/client.test.ts b/packages/sdk/test/client.test.ts
index e2ca7c7..4764dc4 100644
--- a/packages/sdk/test/client.test.ts
+++ b/packages/sdk/test/client.test.ts
@@ -5,32 +5,33 @@ import { stubFetch } from './helpers';
describe('top-level client operations', () => {
it('health sends GET /healthz', async () => {
const { fetch, calls } = stubFetch({
- body: { status: 'ok', version: '0.8.0', internal_schema_version: 4 },
+ body: { status: 'ok', version: '0.10.0', internal_schema_version: 6 },
});
const og = new Omnigraph({ baseUrl: 'http://x', fetch });
const h = await og.health();
expect(calls[0]?.method).toBe('GET');
expect(calls[0]?.url).toBe('http://x/healthz');
expect(h.status).toBe('ok');
- expect(h.version).toBe('0.8.0');
- // New in server 0.8.0: the storage-format version, camelized like any field.
- expect(h.internalSchemaVersion).toBe(4);
+ expect(h.version).toBe('0.10.0');
+ expect(h.internalSchemaVersion).toBe(6);
});
it('snapshot encodes branch as a query param', async () => {
const { fetch, calls } = stubFetch({
- body: { branch: 'main', tables: [], snapshot_id: 'snap-1' },
+ body: { graph_branch: 'main', graph_manifest_version: 3, internal_schema_version: 6, datasets: [] },
});
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/graphs/g/snapshot?branch=main');
- expect(s.branch).toBe('main');
+ expect(s.graphBranch).toBe('main');
+ expect(s.graphManifestVersion).toBe(3);
+ expect(s.datasets).toEqual([]);
});
it('snapshot allows omitting branch (server default)', async () => {
const { fetch, calls } = stubFetch({
- body: { branch: 'main', tables: [], snapshot_id: 'snap-1' },
+ body: { graph_branch: 'main', graph_manifest_version: 3, internal_schema_version: 6, datasets: [] },
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
await og.snapshot();
@@ -45,7 +46,10 @@ describe('top-level client operations', () => {
branch: 'main',
branch_created: false,
mode: 'merge',
- tables: [],
+ nodes: [{ name: 'Person', entities_loaded: 1 }],
+ edges: [],
+ total_entities: 1,
+ commit: { graph_commit_id: 'c1', graph_manifest_version: 4, created_at: 1714000000000000 },
uri: 's3://x',
},
});
@@ -59,6 +63,9 @@ describe('top-level client operations', () => {
expect(calls[0]?.url).toBe('http://x/graphs/g/load');
expect(r.baseBranch).toBeNull();
expect(r.branchCreated).toBe(false);
+ expect(r.totalEntities).toBe(1);
+ expect(r.nodes[0]?.entitiesLoaded).toBe(1);
+ expect(r.commit?.graphCommitId).toBe('c1');
});
it('loadNdjson sends the raw NDJSON body with the x-ndjson content type', async () => {
@@ -72,8 +79,10 @@ describe('top-level client operations', () => {
branch: 'main',
branch_created: false,
mode: 'merge',
- nodes: [{ name: 'Person', rows: 1 }],
- edges: [{ name: 'Knows', rows: 1 }],
+ nodes: [{ name: 'Person', entities_loaded: 1 }],
+ edges: [{ name: 'Knows', entities_loaded: 1 }],
+ total_entities: 2,
+ commit: { graph_commit_id: 'c2', graph_manifest_version: 5, created_at: 1714000000000001 },
},
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
@@ -86,6 +95,9 @@ describe('top-level client operations', () => {
expect(calls[0]?.headers['content-type']).toBe('application/x-ndjson');
expect(r.branchCreated).toBe(false);
expect(r.nodes[0]?.name).toBe('Person');
+ expect(r.edges[0]?.entitiesLoaded).toBe(1);
+ expect(r.totalEntities).toBe(2);
+ expect(r.commit?.graphCommitId).toBe('c2');
});
it('loadNdjson omits absent query params entirely', async () => {
@@ -100,10 +112,43 @@ describe('top-level client operations', () => {
});
describe('og.query and og.mutate (canonical successors to read/change)', () => {
+ it('uses the dedicated conditional route and preserves the raw commit token', async () => {
+ const { fetch, calls } = stubFetch({
+ body: { branch: 'main', query_name: 'q', affected_nodes: 0, affected_edges: 0, commit: null },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const result = await og.mutate(
+ { query: 'query q($userId: String) { delete Person where id = $userId }', params: { userId: 'absent' } },
+ { ifGraphCommit: 'commit-1' },
+ );
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toBe('http://x/graphs/g/mutate/if-graph-commit');
+ expect(calls[0]?.headers['omnigraph-if-graph-commit']).toBe('commit-1');
+ expect(JSON.parse(calls[0]?.body ?? '{}').params).toEqual({ userId: 'absent' });
+ expect(result.commit).toBeNull();
+ });
+
+ it('never falls back to an unconditional mutation on an older server', async () => {
+ const { fetch, calls } = stubFetch({ status: 404, body: { error: 'not found' } });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(og.mutate({ query: 'query q() { delete Person where id = "a" }' }, { ifGraphCommit: 'old' }))
+ .rejects.toMatchObject({ status: 404 });
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toBe('http://x/graphs/g/mutate/if-graph-commit');
+ });
+
+ it('an empty conditional token still selects the guarded route, never an ordinary write', async () => {
+ const { fetch, calls } = stubFetch({ status: 400, body: { error: 'invalid commit' } });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(og.mutate({ query: 'query q() {}' }, { ifGraphCommit: '' })).rejects.toMatchObject({ status: 400 });
+ expect(calls[0]?.url).toBe('http://x/graphs/g/mutate/if-graph-commit');
+ });
+
it('og.query sends POST /query with canonical snake_case body and camelizes response', async () => {
const { fetch, calls } = stubFetch({
body: {
query_name: 'find',
+ graph_commit_id: 'read-cut',
target: { branch: 'main', snapshot: null },
row_count: 1,
columns: ['$p.name'],
@@ -126,6 +171,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => {
expect(body.params).toEqual({ name: 'Alice' });
expect(r.queryName).toBe('find');
expect(r.rowCount).toBe(1);
+ expect(r.graphCommitId).toBe('read-cut');
});
it('og.query preserves opaque param keys verbatim', async () => {
@@ -149,6 +195,7 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => {
affected_nodes: 1,
branch: 'feature',
query_name: 'addPerson',
+ commit: { graph_commit_id: 'c3', graph_branch: 'feature', graph_manifest_version: 6, created_at: 1714000000000002 },
},
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
@@ -166,6 +213,9 @@ describe('og.query and og.mutate (canonical successors to read/change)', () => {
expect(body.branch).toBe('feature');
expect(body.params).toEqual({ name: 'Frank' });
expect(r.affectedNodes).toBe(1);
+ expect(r.commit?.graphCommitId).toBe('c3');
+ expect(r.commit?.graphBranch).toBe('feature');
+ expect(calls[0]?.headers['omnigraph-if-graph-commit']).toBeUndefined();
});
});
@@ -206,17 +256,18 @@ describe('export streaming options', () => {
data: { name: string };
}
const ndjson = '{"type":"Person","data":{"name":"Alice"}}\n';
- const { fetch } = stubFetch({
+ const { fetch, calls } = stubFetch({
body: ndjson,
headers: { 'content-type': 'application/x-ndjson' },
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
const rows: PersonRow[] = [];
- for await (const row of og.export({ branch: 'main' })) {
+ for await (const row of og.export({ branch: 'main', typeNames: ['Person'] })) {
rows.push(row);
}
expect(rows).toHaveLength(1);
expect(rows[0]?.data.name).toBe('Alice');
+ expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ branch: 'main', type_names: ['Person'] });
});
it('aborts mid-stream when the signal is triggered', async () => {
diff --git a/packages/sdk/test/commits.test.ts b/packages/sdk/test/commits.test.ts
index ee0881d..0113f96 100644
--- a/packages/sdk/test/commits.test.ts
+++ b/packages/sdk/test/commits.test.ts
@@ -9,8 +9,8 @@ describe('commits resource', () => {
commits: [
{
graph_commit_id: '01KQ',
- manifest_branch: null,
- manifest_version: 2,
+ graph_branch: null,
+ graph_manifest_version: 2,
parent_commit_id: null,
merged_parent_commit_id: null,
actor_id: null,
@@ -26,7 +26,7 @@ describe('commits resource', () => {
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(1);
expect(result[0]?.graphCommitId).toBe('01KQ');
- expect(result[0]?.manifestVersion).toBe(2);
+ expect(result[0]?.graphManifestVersion).toBe(2);
expect(result[0]?.createdAt).toBe(1777483011551924);
});
@@ -41,8 +41,8 @@ describe('commits resource', () => {
const { fetch, calls } = stubFetch({
body: {
graph_commit_id: '01KQ/X',
- manifest_branch: null,
- manifest_version: 1,
+ graph_branch: null,
+ graph_manifest_version: 1,
parent_commit_id: null,
merged_parent_commit_id: null,
actor_id: null,
@@ -71,4 +71,78 @@ describe('commits resource', () => {
expect((e as NotFoundError).requestId).toBe('01XYZ');
}
});
+
+ it('changes encodes commit id and repeated filters while preserving entity properties', async () => {
+ const properties = {
+ first_name: 'Ada',
+ nested_value: { userKey: 1, other_key: 2 },
+ };
+ const { fetch, calls } = stubFetch({
+ body: {
+ cause: {
+ graph_commit_id: '01KQ/X',
+ authored_branch: 'feature',
+ authored_at: 123,
+ },
+ changes: [
+ {
+ kind: 'edge',
+ type: { id: 'stable-type-id', name: 'Knows' },
+ id: 'e',
+ op: 'update',
+ before: { properties, endpoints: { from: 'a', to: 'b' } },
+ after: { properties, endpoints: { from: 'a', to: 'c' } },
+ },
+ ],
+ next_page_token: 'opaque-page',
+ },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const signal = new AbortController().signal;
+ const result = await og.commits.changes(
+ '01KQ/X',
+ {
+ pageToken: 'page+/=',
+ limit: 50,
+ kind: ['node', 'edge'],
+ type: ['Person', 'Knows'],
+ op: ['insert', 'update'],
+ },
+ { signal },
+ );
+ expect(calls[0]?.method).toBe('GET');
+ const url = new URL(calls[0]!.url);
+ expect(url.pathname).toBe('/graphs/g/commits/01KQ%2FX/changes');
+ expect(url.searchParams.get('page_token')).toBe('page+/=');
+ expect(url.searchParams.get('limit')).toBe('50');
+ expect(url.searchParams.getAll('kind')).toEqual(['node', 'edge']);
+ expect(url.searchParams.getAll('type')).toEqual(['Person', 'Knows']);
+ expect(url.searchParams.getAll('op')).toEqual(['insert', 'update']);
+ expect(result.cause.graphCommitId).toBe('01KQ/X');
+ expect(result.nextPageToken).toBe('opaque-page');
+ expect(result.changes[0]?.before?.properties).toEqual(properties);
+ expect(result.changes[0]?.after?.endpoints).toEqual({ from: 'a', to: 'c' });
+ });
+
+ it('changes omits absent params and does not turn a schema refusal into an empty diff', async () => {
+ const { fetch, calls } = stubFetch({
+ status: 409,
+ body: {
+ error: 'Cannot cross schema boundary',
+ code: 'conflict',
+ change_diff_refusal: {
+ graph_commit_id: 'c',
+ reason: 'schema_boundary',
+ },
+ },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(og.commits.changes('c')).rejects.toMatchObject({
+ status: 409,
+ body: {
+ changeDiffRefusal: { graphCommitId: 'c', reason: 'schema_boundary' },
+ },
+ });
+ expect(calls[0]?.url).toBe('http://x/graphs/g/commits/c/changes');
+ });
});
diff --git a/packages/sdk/test/e2e.test.ts b/packages/sdk/test/e2e.test.ts
index 651c35e..e04a6b9 100644
--- a/packages/sdk/test/e2e.test.ts
+++ b/packages/sdk/test/e2e.test.ts
@@ -5,20 +5,23 @@
//
// dir=$(mktemp -d)
// cp packages/sdk/test/fixtures/schema.pg "$dir/graph.pg"
+// cp packages/sdk/test/fixtures/queries.gq "$dir/queries.gq"
// cat > "$dir/cluster.yaml" <<'YAML'
// version: 1
// metadata: { name: e2e }
// state: { backend: cluster, lock: true }
// graphs:
-// alpha: { schema: ./graph.pg }
-// beta: { schema: ./graph.pg }
+// alpha: { schema: ./graph.pg, queries: [./queries.gq] }
+// beta: { schema: ./graph.pg, queries: [./queries.gq] }
// 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_*).
+// # per-graph data actions (read/export/change/schema_apply/branch_*/invoke_query).
+// omnigraph lint --schema "$dir/graph.pg" --query "$dir/queries.gq"
// omnigraph cluster import --config "$dir"
+// omnigraph cluster plan --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"
@@ -28,28 +31,38 @@
// 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.
+// source pinned in the repo-root package.json (release tag or exact commit).
+import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import Omnigraph, {
BadRequestError,
BranchMergeOutcome,
LoadMode,
NotFoundError,
+ PreconditionFailedError,
+ RangeNotSatisfiableError,
SERVER_VERSION,
UnauthorizedError,
+ type ChangeBaselineRecord,
+ type EntityChange,
} from '../src';
const E2E_ENABLED = process.env.OMNIGRAPH_E2E === '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;
+const FIXTURE_QUERIES = readFileSync(new URL('./fixtures/queries.gq', import.meta.url), 'utf8');
// Track branches to clean up after the suite — best-effort, since a recent
// merge can leave a branch flagged 'active' transiently. See MR-811 family.
const branchesToCleanup: string[] = [];
let og: Omnigraph;
+function findPerson(branch: string, name: string) {
+ return og.query({ query: FIXTURE_QUERIES, name: 'find_person', params: { name }, branch });
+}
+
describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
beforeAll(() => {
// 0.7.0 is cluster-only: graph-scoped ops require a graphId. Fail loud
@@ -97,19 +110,20 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
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);
+ expect(s.graphBranch).toBe('main');
+ expect(s.datasets.length).toBeGreaterThan(0);
});
});
describe('snapshot', () => {
- it('GET /snapshot?branch=main returns tables with row counts', async () => {
+ it('GET /snapshot?branch=main returns node and edge entity counts', async () => {
const s = await og.snapshot({ branch: 'main' });
- expect(s.branch).toBe('main');
- expect(Array.isArray(s.tables)).toBe(true);
- expect(s.tables.length).toBeGreaterThan(0);
- const person = s.tables.find((t) => t.tableKey?.includes('Person'));
- expect(person?.rowCount).toBeGreaterThanOrEqual(4);
+ expect(s.graphBranch).toBe('main');
+ expect(s.graphManifestVersion).toBeGreaterThan(0);
+ const person = s.datasets.find((d) => d.typeName === 'Person' && d.entityKind === 'node');
+ expect(person?.entityCount).toBe(4);
+ const knows = s.datasets.find((d) => d.typeName === 'Knows' && d.entityKind === 'edge');
+ expect(knows?.entityCount).toBe(3);
});
});
@@ -176,18 +190,62 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
expect((r.rows as unknown[]).length).toBeGreaterThanOrEqual(2);
});
- it('mutate inserts a row on a fresh branch', async () => {
+ it('conditional mutation commits once, rejects stale heads, and reports no-op null', async () => {
const branch = `e2e-mutate-${Date.now()}`;
branchesToCleanup.push(branch);
await og.branches.create({ name: branch, from: 'main' });
+ const name = `e2e-frank-${Date.now()}`;
+ const before = await findPerson(branch, name);
+ expect(before.rows).toEqual([]);
+ expect(before.graphCommitId).toEqual(expect.any(String));
const ch = await og.mutate({
- query:
- 'query addPerson($name: String, $age: I32) { insert Person { name: $name, age: $age } }',
- name: 'addPerson',
- params: { name: `e2e-frank-${Date.now()}`, age: 50 },
+ query: FIXTURE_QUERIES,
+ name: 'add_person',
+ params: { name, age: 50 },
branch,
+ }, { ifGraphCommit: before.graphCommitId! });
+ expect(ch.affectedNodes).toBe(1);
+ expect(ch.commit).toMatchObject({ graphBranch: branch, parentCommitId: before.graphCommitId });
+ expect(await og.commits.retrieve(ch.commit!.graphCommitId)).toEqual(ch.commit);
+ const committed = await findPerson(branch, name);
+ expect(committed.graphCommitId).toBe(ch.commit!.graphCommitId);
+ expect(committed.rows).toEqual([{ name, age: 50 }]);
+
+ const stale = og.mutate({
+ query: FIXTURE_QUERIES, name: 'set_age', params: { name, age: 99 }, branch,
+ }, { ifGraphCommit: before.graphCommitId! });
+ await expect(stale).rejects.toBeInstanceOf(PreconditionFailedError);
+ await expect(stale).rejects.toMatchObject({
+ status: 412,
+ preconditionFailure: { expected: before.graphCommitId, actual: ch.commit!.graphCommitId },
});
- expect((ch.affectedNodes ?? 0)).toBeGreaterThanOrEqual(1);
+ expect(await findPerson(branch, name)).toEqual(committed);
+
+ const noOp = await og.mutate({
+ query: FIXTURE_QUERIES, name: 'set_age', params: { name: 'e2e-missing-person', age: 99 }, branch,
+ }, { ifGraphCommit: ch.commit!.graphCommitId });
+ expect(noOp).toMatchObject({ affectedNodes: 0, affectedEdges: 0, commit: null });
+ expect(await findPerson(branch, name)).toEqual(committed);
+ });
+
+ it('stored mutations use the conditional capability route', async () => {
+ const branch = `e2e-stored-${Date.now()}`;
+ branchesToCleanup.push(branch);
+ await og.branches.create({ name: branch, from: 'main' });
+ const before = await findPerson(branch, 'Alice');
+ const changed = await og.queries.invoke('set_age', {
+ params: { name: 'Alice', age: 31 }, branch, expectMutation: true,
+ }, { ifGraphCommit: before.graphCommitId! });
+ expect(changed).toMatchObject({ affectedNodes: 1 });
+ if (!('affectedNodes' in changed)) throw new Error('stored mutation returned a read envelope');
+ expect(await og.commits.retrieve(changed.commit!.graphCommitId)).toEqual(changed.commit);
+ const committed = await findPerson(branch, 'Alice');
+ expect(committed.rows).toEqual([{ name: 'Alice', age: 31 }]);
+ expect(committed.graphCommitId).toBe(changed.commit!.graphCommitId);
+ await expect(og.queries.invoke('set_age', {
+ params: { name: 'Alice', age: 99 }, branch, expectMutation: true,
+ }, { ifGraphCommit: before.graphCommitId! })).rejects.toBeInstanceOf(PreconditionFailedError);
+ expect(await findPerson(branch, 'Alice')).toEqual(committed);
});
});
@@ -205,7 +263,11 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
data: JSON.stringify({ type: 'Person', data: { name, age: 33 } }) + '\n',
});
expect(result.branch).toBe(branch);
- expect(result.tables.length).toBeGreaterThan(0);
+ expect(result.nodes).toEqual([{ name: 'Person', entitiesLoaded: 1 }]);
+ expect(result.edges).toEqual([]);
+ expect(result.totalEntities).toBe(1);
+ expect(result.commit?.graphBranch).toBe(branch);
+ expect(await og.commits.retrieve(result.commit!.graphCommitId)).toEqual(result.commit);
const r = await og.query({
query:
@@ -215,6 +277,7 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
branch,
});
expect((r.rows as unknown[]).length).toBe(1);
+ expect(r.graphCommitId).toBe(result.commit!.graphCommitId);
});
// New endpoint in server 0.9.0: strict bounded graph-level NDJSON batch.
@@ -231,7 +294,9 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
ndjson: JSON.stringify({ type: 'Person', data: { name, age: 44 } }) + '\n',
});
expect(result.branch).toBe(branch);
- expect(result.nodes.length).toBeGreaterThan(0);
+ expect(result.nodes).toEqual([{ name: 'Person', entitiesLoaded: 1 }]);
+ expect(result.totalEntities).toBe(1);
+ expect(await og.commits.retrieve(result.commit!.graphCommitId)).toEqual(result.commit);
const r = await og.query({
query:
@@ -241,6 +306,7 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
branch,
});
expect((r.rows as unknown[]).length).toBe(1);
+ expect(r.graphCommitId).toBe(result.commit!.graphCommitId);
});
});
@@ -267,6 +333,105 @@ describe.skipIf(!E2E_ENABLED)('e2e: live omnigraph-server', () => {
});
});
+ describe('logical changes', () => {
+ it('baselines nodes and edges, pages a commit, and checkpoints only complete feed pages', async () => {
+ const branch = `e2e-feed-${Date.now()}`;
+ const name = `e2e-friend-${Date.now()}`;
+ branchesToCleanup.push(branch);
+ await og.branches.create({ name: branch, from: 'main' });
+
+ const baseline: ChangeBaselineRecord[] = [];
+ for await (const record of og.changes.baseline({ branch })) baseline.push(record);
+ const terminal = baseline.pop();
+ if (!terminal || !('baseline' in terminal)) throw new Error('baseline has no terminal cursor');
+ expect(baseline.every((record) => !('baseline' in record))).toBe(true);
+ expect(baseline.some((record) => 'type' in record && record.type === 'Person')).toBe(true);
+ expect(baseline.some((record) => 'edge' in record && record.edge === 'Knows')).toBe(true);
+ const before = await findPerson(branch, name);
+ expect(terminal.baseline.snapshotCommitId).toBe(before.graphCommitId);
+
+ const changed = await og.mutate({
+ query: FIXTURE_QUERIES, name: 'add_friend', params: { name, age: 28, from: 'Alice' }, branch,
+ });
+ expect(changed).toMatchObject({ affectedNodes: 1, affectedEdges: 1 });
+ const commitId = changed.commit!.graphCommitId;
+
+ const first = await og.commits.changes(commitId, { limit: 1 });
+ expect(first.cause.graphCommitId).toBe(commitId);
+ expect(first.changes).toHaveLength(1);
+ expect(first.nextPageToken).toEqual(expect.any(String));
+ const second = await og.commits.changes(commitId, { limit: 1, pageToken: first.nextPageToken! });
+ expect(second.cause.graphCommitId).toBe(commitId);
+ expect(second.changes).toHaveLength(1);
+ expect(second.nextPageToken ?? null).toBeNull();
+ const changes: EntityChange[] = [...first.changes, ...second.changes];
+ expect(changes.find((change) => change.kind === 'node')).toMatchObject({
+ id: name, op: 'insert', type: { name: 'Person' }, after: { properties: { name, age: 28 } },
+ });
+ expect(changes.find((change) => change.kind === 'edge')).toMatchObject({
+ op: 'insert', type: { name: 'Knows' }, after: { endpoints: { from: 'Alice', to: name } },
+ });
+
+ const partial = await og.changes.poll({ branch, cursor: terminal.baseline.resumeCursor, limit: 1 });
+ expect(partial.cursor ?? null).toBeNull(); // A page token is NOT a durable checkpoint.
+ expect(partial.nextPageToken).toEqual(expect.any(String));
+ expect(partial.blocks.flatMap((block) => block.changes)).toEqual(first.changes);
+ const completed = await og.changes.poll({ branch, pageToken: partial.nextPageToken!, limit: 1 });
+ expect(completed.nextPageToken ?? null).toBeNull();
+ expect(completed.cursor).toEqual(expect.any(String));
+ expect(completed.blocks.flatMap((block) => block.changes)).toEqual(second.changes);
+ for (const block of [...partial.blocks, ...completed.blocks]) {
+ expect(block.cause.graphCommitId).toBe(commitId);
+ }
+ const caughtUp = await og.changes.poll({ branch, cursor: completed.cursor! });
+ expect(caughtUp).toMatchObject({ blocks: [], caughtUp: true });
+ });
+ });
+
+ describe('managed Blob delivery', () => {
+ it('GET/HEAD, ranges, and validators preserve byte and metadata semantics', async () => {
+ const read = await findPerson('main', 'Alice');
+ expect(read.graphCommitId).toEqual(expect.any(String));
+ const selector = { entity: 'node' as const, type: 'Person', id: 'Alice', property: 'avatar', snapshot: read.graphCommitId! };
+ const full = await og.blobs.get(selector);
+ expect(full.status).toBe(200);
+ expect(full.headers.get('content-type')).toBe('application/octet-stream');
+ expect(full.headers.get('content-length')).toBe('11');
+ expect(full.headers.get('accept-ranges')).toBe('bytes');
+ const etag = full.headers.get('etag');
+ // This header describes the resolved physical snapshot; it is opaque
+ // evidence, NOT a graph commit id usable as the snapshot request value.
+ const resolvedSnapshot = full.headers.get('omnigraph-snapshot-id');
+ expect(etag).toMatch(/^".+"$/);
+ expect(resolvedSnapshot).toEqual(expect.any(String));
+ expect(await full.text()).toBe('Hello World');
+
+ const head = await og.blobs.stat({ ...selector, range: 'bytes=1-4' });
+ expect(head.status).toBe(200);
+ expect(head.headers.get('content-length')).toBe('11'); // HEAD ignores Range.
+ expect(head.headers.get('etag')).toBe(etag);
+ expect(head.headers.get('omnigraph-snapshot-id')).toBe(resolvedSnapshot);
+ expect(await head.text()).toBe('');
+
+ const ranged = await og.blobs.get({ ...selector, range: 'bytes=1-4', ifRange: etag! });
+ expect(ranged.status).toBe(206);
+ expect(ranged.headers.get('content-range')).toBe('bytes 1-4/11');
+ expect(await ranged.text()).toBe('ello');
+ const notModified = await og.blobs.get({ ...selector, ifNoneMatch: etag! });
+ expect(notModified.status).toBe(304);
+ expect(notModified.headers.get('etag')).toBe(etag);
+ expect(await notModified.text()).toBe('');
+
+ await expect(og.blobs.get({ ...selector, ifMatch: '"stale"' })).rejects.toBeInstanceOf(PreconditionFailedError);
+ await expect(og.blobs.stat({ ...selector, ifMatch: '"stale"' })).rejects.toMatchObject({
+ name: 'PreconditionFailedError', status: 412,
+ });
+ const unsatisfiable = og.blobs.get({ ...selector, range: 'bytes=99-' });
+ await expect(unsatisfiable).rejects.toBeInstanceOf(RangeNotSatisfiableError);
+ await expect(unsatisfiable).rejects.toMatchObject({ status: 416 });
+ });
+ });
+
describe('schema', () => {
it('get returns the persisted .pg source', async () => {
const s = await og.schema.get();
diff --git a/packages/sdk/test/enums.test.ts b/packages/sdk/test/enums.test.ts
index d684870..c603d26 100644
--- a/packages/sdk/test/enums.test.ts
+++ b/packages/sdk/test/enums.test.ts
@@ -46,7 +46,9 @@ describe('runtime enum constants', () => {
branch: 'feat',
branch_created: false,
mode: 'merge',
- tables: [],
+ nodes: [],
+ edges: [],
+ total_entities: 0,
uri: 's3://x',
},
});
diff --git a/packages/sdk/test/errors.test.ts b/packages/sdk/test/errors.test.ts
index 23049dd..52cf057 100644
--- a/packages/sdk/test/errors.test.ts
+++ b/packages/sdk/test/errors.test.ts
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest';
import Omnigraph, {
BadRequestError,
ConflictError,
+ GoneError,
+ PreconditionFailedError,
+ PayloadTooLargeError,
+ RangeNotSatisfiableError,
+ FailedDependencyError,
+ ServiceUnavailableError,
ForbiddenError,
InternalServerError,
MethodNotAllowedError,
@@ -18,6 +24,9 @@ const cases: Array<[number, string, unknown]> = [
[404, 'not_found', NotFoundError],
[405, 'method_not_allowed', MethodNotAllowedError],
[409, 'conflict', ConflictError],
+ [412, 'conflict', PreconditionFailedError],
+ [413, 'bad_request', PayloadTooLargeError],
+ [416, 'bad_request', RangeNotSatisfiableError],
[429, 'too_many_requests', TooManyRequestsError],
[500, 'internal', InternalServerError],
];
@@ -43,13 +52,16 @@ describe('error dispatcher', () => {
await expect(og.graphs.list()).rejects.toBeInstanceOf(MethodNotAllowedError);
});
- it('ConflictError exposes manifestConflict when present', async () => {
+ it('ConflictError exposes published dataset version details', async () => {
const { fetch } = stubFetch({
status: 409,
body: {
error: 'manifest version mismatch',
code: 'conflict',
- manifest_conflict: { actual: 7, expected: 5, table_key: 'Person' },
+ published_dataset_version_conflict: {
+ entity_kind: 'node', type_name: 'Person',
+ actual_published_dataset_version: 7, expected_published_dataset_version: 5,
+ },
},
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
@@ -59,7 +71,10 @@ describe('error dispatcher', () => {
} catch (e) {
expect(e).toBeInstanceOf(ConflictError);
const err = e as ConflictError;
- expect(err.manifestConflict).toEqual({ actual: 7, expected: 5, tableKey: 'Person' });
+ expect(err.publishedDatasetVersionConflict).toEqual({
+ entityKind: 'node', typeName: 'Person',
+ actualPublishedDatasetVersion: 7, expectedPublishedDatasetVersion: 5,
+ });
}
});
@@ -73,8 +88,9 @@ describe('error dispatcher', () => {
{
kind: 'divergent_update',
message: 'two branches updated row 1',
- row_id: 'r1',
- table_key: 'Person',
+ entity_id: 'r1',
+ entity_kind: 'node',
+ type_name: 'Person',
},
],
},
@@ -88,6 +104,27 @@ describe('error dispatcher', () => {
const err = e as ConflictError;
expect(err.mergeConflicts).toHaveLength(1);
expect(err.mergeConflicts?.[0]?.kind).toBe('divergent_update');
+ expect(err.mergeConflicts?.[0]).toMatchObject({ entityId: 'r1', entityKind: 'node', typeName: 'Person' });
}
});
+
+ it.each([
+ [410, GoneError, 'change_feed_gap', 'changeFeedGap', { first_unreadable_commit_id: 'c1' }, { firstUnreadableCommitId: 'c1' }],
+ [412, PreconditionFailedError, 'precondition_failure', 'preconditionFailure', { expected: 'c1', actual: 'c2' }, { expected: 'c1', actual: 'c2' }],
+ [413, PayloadTooLargeError, 'resource_limit', 'resourceLimit', { resource: 'entities', limit: 10, actual: 11 }, { resource: 'entities', limit: 10, actual: 11 }],
+ [416, RangeNotSatisfiableError, 'blob_range', 'blobRange', { start: 10, end: 20, length: 5 }, { start: 10, end: 20, length: 5 }],
+ [424, FailedDependencyError, 'external_blob_source', 'externalBlobSource', { uri: 's3://example/item', reason: 'unavailable' }, { uri: 's3://example/item', reason: 'unavailable' }],
+ [503, ServiceUnavailableError, 'recovery_required', 'recoveryRequired', { operation_id: 'op1' }, { operationId: 'op1' }],
+ [409, ConflictError, 'full_text_index_rebuild_required', 'fullTextIndexRebuildRequired', { index: 'Document_text', reason: 'uncertified' }, { index: 'Document_text', reason: 'uncertified' }],
+ [409, ConflictError, 'change_diff_refusal', 'changeDiffRefusal', { graph_commit_id: 'c1', reason: 'parentless_commit' }, { graphCommitId: 'c1', reason: 'parentless_commit' }],
+ [409, ConflictError, 'key_conflict', 'keyConflict', { entity_kind: 'node', type_name: 'Person', entity_id: 'a' }, { entityKind: 'node', typeName: 'Person', entityId: 'a' }],
+ [409, ConflictError, 'read_set_conflict', 'readSetConflict', { member: 'graph_head', expected: 'a', actual: 'b' }, { member: 'graph_head', expected: 'a', actual: 'b' }],
+ ] as const)('preserves structured HTTP %i details (%s)', async (status, cls, wireKey, publicKey, detail, expected) => {
+ const { fetch, calls } = stubFetch({ status, body: { error: 'refused', [wireKey]: detail } });
+ const og = new Omnigraph({ baseUrl: 'http://x', fetch });
+ const failure = await og.health().catch((e: unknown) => e);
+ expect(failure).toBeInstanceOf(cls);
+ expect(failure).toMatchObject({ [publicKey]: expected, body: { [publicKey]: expected } });
+ expect(calls).toHaveLength(1);
+ });
});
diff --git a/packages/sdk/test/fixtures/data.jsonl b/packages/sdk/test/fixtures/data.jsonl
index 8526dce..3065a8b 100644
--- a/packages/sdk/test/fixtures/data.jsonl
+++ b/packages/sdk/test/fixtures/data.jsonl
@@ -1,4 +1,4 @@
-{"type": "Person", "data": {"name": "Alice", "age": 30}}
+{"type": "Person", "data": {"name": "Alice", "age": 30, "avatar": "base64:SGVsbG8gV29ybGQ="}}
{"type": "Person", "data": {"name": "Bob", "age": 25}}
{"type": "Person", "data": {"name": "Charlie", "age": 35}}
{"type": "Person", "data": {"name": "Dana", "age": 40}}
diff --git a/packages/sdk/test/fixtures/queries.gq b/packages/sdk/test/fixtures/queries.gq
new file mode 100644
index 0000000..08437a1
--- /dev/null
+++ b/packages/sdk/test/fixtures/queries.gq
@@ -0,0 +1,17 @@
+query find_person($name: String) {
+ match { $p: Person { name: $name } }
+ return { $p.name as name, $p.age as age }
+}
+
+query add_person($name: String, $age: I32) {
+ insert Person { name: $name, age: $age }
+}
+
+query set_age($name: String, $age: I32) {
+ update Person set { age: $age } where name = $name
+}
+
+query add_friend($name: String, $age: I32, $from: String) {
+ insert Person { name: $name, age: $age }
+ insert Knows { from: $from, to: $name }
+}
diff --git a/packages/sdk/test/fixtures/schema.pg b/packages/sdk/test/fixtures/schema.pg
index 6dcf9ce..252af94 100644
--- a/packages/sdk/test/fixtures/schema.pg
+++ b/packages/sdk/test/fixtures/schema.pg
@@ -1,6 +1,7 @@
node Person {
name: String @key
age: I32?
+ avatar: Blob?
}
node Company {
diff --git a/packages/sdk/test/queries.test.ts b/packages/sdk/test/queries.test.ts
index f3d5cbb..5fa3e2c 100644
--- a/packages/sdk/test/queries.test.ts
+++ b/packages/sdk/test/queries.test.ts
@@ -3,6 +3,27 @@ import Omnigraph, { BadRequestError } from '../src';
import { stubFetch } from './helpers';
describe('queries resource (stored queries)', () => {
+ it('guards a stored mutation on its dedicated route without converting params', async () => {
+ const { fetch, calls } = stubFetch({
+ body: { branch: 'main', query_name: 'retitle', affected_nodes: 1, affected_edges: 0,
+ commit: { graph_commit_id: 'c2', graph_manifest_version: 2, created_at: 1714000000000000 } },
+ });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ const result = await og.queries.invoke('retitle item', { params: { displayName: 'new' }, expectMutation: true }, { ifGraphCommit: 'c1' });
+ expect(calls[0]?.url).toBe('http://x/graphs/g/queries/retitle%20item/if-graph-commit');
+ expect(calls[0]?.headers['omnigraph-if-graph-commit']).toBe('c1');
+ expect(JSON.parse(calls[0]?.body ?? '{}')).toEqual({ params: { displayName: 'new' }, expect_mutation: true });
+ expect(result).toMatchObject({ commit: { graphCommitId: 'c2', graphManifestVersion: 2 } });
+ });
+
+ it('does not retry a missing conditional stored-mutation route', async () => {
+ const { fetch, calls } = stubFetch({ status: 404, body: { error: 'not found' } });
+ const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
+ await expect(og.queries.invoke('q', {}, { ifGraphCommit: 'c1' })).rejects.toMatchObject({ status: 404 });
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toBe('http://x/graphs/g/queries/q/if-graph-commit');
+ });
+
it('list sends GET /queries and camelizes the catalog', async () => {
const { fetch, calls } = stubFetch({
body: {
@@ -34,6 +55,7 @@ describe('queries resource (stored queries)', () => {
const { fetch, calls } = stubFetch({
body: {
query_name: 'find_inactive',
+ graph_commit_id: 'read-cut',
row_count: 1,
columns: ['$u.name'],
rows: [{ '$u.name': 'Alice' }],
@@ -54,6 +76,7 @@ describe('queries resource (stored queries)', () => {
const read = r as { rowCount: number; rows: Array> };
expect(read.rowCount).toBe(1);
expect(read.rows[0]?.['$u.name']).toBe('Alice');
+ expect(r).toMatchObject({ graphCommitId: 'read-cut' });
});
it('serializes expectMutation → expect_mutation on the wire', async () => {
diff --git a/packages/sdk/test/schema.test.ts b/packages/sdk/test/schema.test.ts
index a3899f9..56f8140 100644
--- a/packages/sdk/test/schema.test.ts
+++ b/packages/sdk/test/schema.test.ts
@@ -18,7 +18,7 @@ describe('schema resource', () => {
const { fetch, calls } = stubFetch({
body: {
applied: true,
- manifest_version: 5,
+ graph_manifest_version: 5,
steps: [],
supported: true,
},
@@ -31,12 +31,12 @@ describe('schema resource', () => {
schema_source: 'node Foo { id: String @key }',
});
expect(r.applied).toBe(true);
- expect(r.manifestVersion).toBe(5);
+ expect(r.graphManifestVersion).toBe(5);
});
it('apply returns applied=false on no-op', async () => {
const { fetch } = stubFetch({
- body: { applied: false, manifest_version: 5, steps: [], supported: true },
+ body: { applied: false, graph_manifest_version: 5, steps: [], supported: true },
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
const r = await og.schema.apply({ schemaSource: 'node Foo { id: String @key }' });
@@ -45,7 +45,7 @@ describe('schema resource', () => {
it('apply serializes allowDataLoss → allow_data_loss on the wire', async () => {
const { fetch, calls } = stubFetch({
- body: { applied: true, manifest_version: 6, steps: [], supported: true },
+ body: { applied: true, graph_manifest_version: 6, steps: [], supported: true },
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
await og.schema.apply({
@@ -60,7 +60,7 @@ describe('schema resource', () => {
it('apply omits allow_data_loss when allowDataLoss is unset', async () => {
const { fetch, calls } = stubFetch({
- body: { applied: true, manifest_version: 6, steps: [], supported: true },
+ body: { applied: true, graph_manifest_version: 6, steps: [], supported: true },
});
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch });
await og.schema.apply({ schemaSource: 'node Foo { id: String @key }' });
@@ -71,7 +71,7 @@ describe('schema resource', () => {
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
+ // 409 — no merge_conflicts / published_dataset_version_conflict payload. The conflict fields
// must stay undefined, not throw.
const { fetch } = stubFetch({
status: 409,
@@ -87,7 +87,7 @@ describe('schema resource', () => {
} catch (e) {
expect(e).toBeInstanceOf(ConflictError);
expect((e as ConflictError).mergeConflicts).toBeUndefined();
- expect((e as ConflictError).manifestConflict).toBeUndefined();
+ expect((e as ConflictError).publishedDatasetVersionConflict).toBeUndefined();
}
});
});
diff --git a/packages/sdk/test/transport.test.ts b/packages/sdk/test/transport.test.ts
index 81fddb4..0f912e1 100644
--- a/packages/sdk/test/transport.test.ts
+++ b/packages/sdk/test/transport.test.ts
@@ -113,10 +113,11 @@ describe('transport graphId prefixing', () => {
{
body: {
graph_commit_id: 'c1',
- manifest_version: 1,
+ graph_manifest_version: 1,
+ created_at: 1,
parent_commit_id: null,
merged_parent_commit_id: null,
- manifest_branch: null,
+ graph_branch: null,
},
},
]);
@@ -130,7 +131,7 @@ describe('transport graphId prefixing', () => {
it('prefixes /schema and /schema/apply under /graphs/{graphId}', async () => {
const { fetch, calls } = stubFetch([
{ body: { schema_source: 'node Person { name: String @key }' } },
- { body: { applied: true, manifest_version: 1, steps: [], supported: true } },
+ { body: { applied: true, graph_manifest_version: 1, steps: [], supported: true, uri: 'file:///example', step_count: 0 } },
]);
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'alpha', fetch });
await og.schema.get();
@@ -146,14 +147,16 @@ describe('transport graphId prefixing', () => {
branch: 'main',
branch_created: false,
mode: 'merge',
- tables: [],
+ nodes: [],
+ edges: [],
+ total_entities: 0,
uri: 's3://x',
};
const { fetch, calls } = stubFetch([
- { body: { rows: [], columns: [] } },
- { body: { affected_nodes: 0, affected_edges: 0 } },
+ { body: { query_name: 'q', target: { branch: 'main' }, rows: [], columns: [], row_count: 0, graph_commit_id: 'c1' } },
+ { body: { branch: 'main', query_name: 'q', affected_nodes: 0, affected_edges: 0, commit: null } },
{ body: loadBody },
- { body: { branch: 'main', tables: [] } },
+ { body: { graph_branch: 'main', graph_manifest_version: 1, internal_schema_version: 6, datasets: [] } },
{ body: '', headers: { 'content-type': 'application/x-ndjson' } },
]);
const og = new Omnigraph({ baseUrl: 'http://x', graphId: 'alpha', fetch });
diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts
index a791201..677b53f 100644
--- a/scripts/check-coverage.ts
+++ b/scripts/check-coverage.ts
@@ -21,7 +21,7 @@ interface Spec {
paths?: Record>;
}
-const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch']);
+const HTTP_METHODS = new Set(['get', 'head', 'post', 'put', 'delete', 'patch', 'options', 'trace']);
interface SpecEndpoint {
method: string;
diff --git a/scripts/check-drift.ts b/scripts/check-drift.ts
index 21388c7..881e582 100644
--- a/scripts/check-drift.ts
+++ b/scripts/check-drift.ts
@@ -1,34 +1,31 @@
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { readServerPin } from './server-pin.js';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
-const ROOT_PKG = join(ROOT, 'package.json');
const SPEC_FILE = join(ROOT, 'spec/openapi.json');
-const pkg = JSON.parse(readFileSync(ROOT_PKG, 'utf8')) as {
- omnigraph?: { serverVersion?: string };
-};
-const version = pkg.omnigraph?.serverVersion;
-if (!version) {
- throw new Error(`omnigraph.serverVersion missing from ${ROOT_PKG}`);
-}
+const { version, ref } = readServerPin();
const local = readFileSync(SPEC_FILE, 'utf8');
-const url = `https://raw.githubusercontent.com/ModernRelay/omnigraph/v${version}/openapi.json`;
+const url = `https://raw.githubusercontent.com/ModernRelay/omnigraph/${ref}/openapi.json`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`fetch failed: ${response.status} ${response.statusText}`);
}
const upstream = await response.text();
+if (JSON.parse(upstream)?.info?.version !== version) {
+ throw new Error(`upstream spec info.version does not match pinned ${version}`);
+}
if (local !== upstream) {
console.error(
- `spec drift: spec/openapi.json does not match upstream at v${version}.\n` +
+ `spec drift: spec/openapi.json does not match upstream at ${ref}.\n` +
`Run \`pnpm run sync-spec\` and regenerate the SDK.`,
);
process.exit(1);
}
-console.log(`spec/openapi.json matches upstream at v${version}`);
+console.log(`spec/openapi.json matches upstream at ${ref} (server ${version})`);
diff --git a/scripts/check-versions.ts b/scripts/check-versions.ts
index 09034ab..40f8b02 100644
--- a/scripts/check-versions.ts
+++ b/scripts/check-versions.ts
@@ -1,8 +1,11 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { assertReleasePin, readServerPin } from './server-pin.js';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
+const pin = readServerPin();
+if (process.argv.includes('--release')) assertReleasePin(pin);
interface PackageJson {
name?: string;
@@ -42,4 +45,4 @@ if (unique.size !== 1) {
process.exit(1);
}
-console.log(`Version check passed: all packages target omnigraph-server v${serverVersion}`);
+console.log(`Version check passed: all packages target omnigraph-server v${serverVersion} (${pin.ref})`);
diff --git a/scripts/server-pin.test.ts b/scripts/server-pin.test.ts
new file mode 100644
index 0000000..7d6a357
--- /dev/null
+++ b/scripts/server-pin.test.ts
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { assertReleasePin, resolveServerPin } from './server-pin.js';
+
+test('released server pins resolve to a tag and permit the release gate', () => {
+ const pin = resolveServerPin({ serverVersion: '0.10.0' });
+ assert.deepEqual(pin, { version: '0.10.0', ref: 'v0.10.0', sourcePinned: false });
+ assert.doesNotThrow(() => assertReleasePin(pin));
+});
+
+test('an immutable source pin keeps the target version but blocks publishing', () => {
+ const serverRef = 'd043cf148e37c4356deb497835db593a2c32d270';
+ const pin = resolveServerPin({ serverVersion: '0.10.0', serverRef });
+ assert.deepEqual(pin, { version: '0.10.0', ref: serverRef, sourcePinned: true });
+ assert.throws(() => assertReleasePin(pin), /Cannot publish a source-pinned server candidate/);
+});
+
+test('mutable, abbreviated, empty, and malformed source pins fail closed', () => {
+ for (const serverRef of ['main', 'v0.10.0', 'd043cf14', '', null, 42, 'a'.repeat(39), 'a'.repeat(41)]) {
+ assert.throws(() => resolveServerPin({ serverVersion: '0.10.0', serverRef }), /full lowercase 40-character commit SHA/);
+ }
+});
+
+test('server version is required even when a source ref is present', () => {
+ for (const serverVersion of [undefined, null, '', 'main', '0.10', 10]) {
+ assert.throws(() => resolveServerPin({ serverVersion, serverRef: 'a'.repeat(40) }), /semantic version/);
+ }
+ assert.equal(resolveServerPin({ serverVersion: '0.10.0-rc.1' }).ref, 'v0.10.0-rc.1');
+});
diff --git a/scripts/server-pin.ts b/scripts/server-pin.ts
new file mode 100644
index 0000000..a6b8898
--- /dev/null
+++ b/scripts/server-pin.ts
@@ -0,0 +1,47 @@
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+export interface ServerPin {
+ version: string;
+ ref: string;
+ sourcePinned: boolean;
+}
+
+/** A release tag by default; an immutable commit only for unreleased development. */
+export function resolveServerPin(config: {
+ serverVersion?: unknown;
+ serverRef?: unknown;
+}): ServerPin {
+ const { serverVersion, serverRef } = config;
+ if (
+ typeof serverVersion !== 'string' ||
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(serverVersion)
+ ) {
+ throw new Error('omnigraph.serverVersion must be a semantic version');
+ }
+ if (serverRef !== undefined && (
+ typeof serverRef !== 'string' || !/^[0-9a-f]{40}$/.test(serverRef)
+ )) {
+ throw new Error('omnigraph.serverRef must be a full lowercase 40-character commit SHA');
+ }
+ return {
+ version: serverVersion,
+ ref: serverRef ?? `v${serverVersion}`,
+ sourcePinned: serverRef !== undefined,
+ };
+}
+
+export function readServerPin(): ServerPin {
+ const path = fileURLToPath(new URL('../package.json', import.meta.url));
+ const pkg = JSON.parse(readFileSync(path, 'utf8'));
+ return resolveServerPin(pkg.omnigraph ?? {});
+}
+
+export function assertReleasePin(pin: ServerPin): void {
+ if (pin.sourcePinned) {
+ throw new Error(
+ 'Cannot publish a source-pinned server candidate. After the server release is tagged, ' +
+ 'remove omnigraph.serverRef, sync the spec, regenerate, and rerun all checks.',
+ );
+ }
+}
diff --git a/scripts/sync-spec.ts b/scripts/sync-spec.ts
index 9a825e0..ba0a665 100644
--- a/scripts/sync-spec.ts
+++ b/scripts/sync-spec.ts
@@ -1,20 +1,14 @@
-import { readFileSync, writeFileSync } from 'node:fs';
+import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
+import { readServerPin } from './server-pin.js';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
-const ROOT_PKG = join(ROOT, 'package.json');
const SPEC_FILE = join(ROOT, 'spec/openapi.json');
-const pkg = JSON.parse(readFileSync(ROOT_PKG, 'utf8')) as {
- omnigraph?: { serverVersion?: string };
-};
-const version = pkg.omnigraph?.serverVersion;
-if (!version) {
- throw new Error(`omnigraph.serverVersion missing from ${ROOT_PKG}`);
-}
+const { version, ref } = readServerPin();
-const url = `https://raw.githubusercontent.com/ModernRelay/omnigraph/v${version}/openapi.json`;
+const url = `https://raw.githubusercontent.com/ModernRelay/omnigraph/${ref}/openapi.json`;
console.log(`fetching ${url}`);
const response = await fetch(url);
diff --git a/spec/openapi.json b/spec/openapi.json
index cbc3e07..a569f78 100644
--- a/spec/openapi.json
+++ b/spec/openapi.json
@@ -7,7 +7,7 @@
"name": "MIT",
"identifier": "MIT"
},
- "version": "0.9.0"
+ "version": "0.10.0"
},
"paths": {
"/graphs": {
@@ -67,14 +67,923 @@
]
}
},
- "/graphs/{graph_id}/branches": {
+ "/graphs/{graph_id}/blob": {
"get": {
+ "tags": [
+ "blobs"
+ ],
+ "summary": "Deliver one logical node or edge Blob cell.",
+ "description": "Managed content is streamed through the bounded transport. External\ndescriptors redirect without target-store I/O. Authorization and target\nresolution share the exact helper used by `/query`.",
+ "operationId": "cluster_getBlob",
+ "parameters": [
+ {
+ "name": "graph_id",
+ "in": "path",
+ "description": "Graph id to route the request to.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "entity",
+ "in": "query",
+ "description": "Select a logical node or edge cell.",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/BlobEntityKind"
+ }
+ },
+ {
+ "name": "type",
+ "in": "query",
+ "description": "Accepted-schema node or edge type name.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "id",
+ "in": "query",
+ "description": "Logical entity id within the selected type.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "property",
+ "in": "query",
+ "description": "Accepted-schema Blob property name.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "branch",
+ "in": "query",
+ "description": "Branch to read. Mutually exclusive with `snapshot`; defaults to `main`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "snapshot",
+ "in": "query",
+ "description": "Immutable graph snapshot id. Mutually exclusive with `branch`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "If-Match",
+ "in": "header",
+ "description": "Strong entity-tag-list precondition, including `*`, evaluated before If-None-Match and Range.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "Range",
+ "in": "header",
+ "description": "One `bytes` range. Malformed, unknown-unit, and multiple ranges are ignored in V1.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "If-None-Match",
+ "in": "header",
+ "description": "Weak entity-tag-list comparison, including `*`, evaluated before Range.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "If-Range",
+ "in": "header",
+ "description": "One strong entity tag. A mismatch causes the complete representation to be served.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Complete managed Blob",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "The literal value `bytes` for managed content"
+ },
+ "Content-Length": {
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "description": "Exact served payload length"
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Strong validator for the selected managed Blob"
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Exact resolved graph snapshot"
+ }
+ },
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary",
+ "description": "OpenAPI-only marker for an unstructured octet-stream response body."
+ }
+ }
+ }
+ },
+ "206": {
+ "description": "One satisfiable managed byte range",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Content-Length": {
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ },
+ "Content-Range": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "content": {
+ "application/octet-stream": {
+ "schema": {
+ "type": "string",
+ "format": "binary",
+ "description": "OpenAPI-only marker for an unstructured octet-stream response body."
+ }
+ }
+ }
+ },
+ "302": {
+ "description": "External Blob descriptor; the server does not dereference it",
+ "headers": {
+ "Cache-Control": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "The literal value `no-store`"
+ },
+ "Location": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Exact stored absolute URI"
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Exact resolved graph snapshot"
+ }
+ }
+ },
+ "304": {
+ "description": "If-None-Match matched the managed Blob validator",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Content-Length": {
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "description": "Complete managed Blob length, as required for a valid 304 Content-Length"
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid selector, target, or non-Blob property",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Unknown entity or null Blob cell",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "412": {
+ "description": "If-Match did not strongly match the selected managed Blob validator",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "416": {
+ "description": "Requested managed byte range is unsatisfiable",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Content-Range": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Unsatisfied range in the form `bytes */N`"
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Stored Blob integrity or pre-header delivery refusal, including ranged external descriptors that cannot be redirected",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ },
+ "head": {
+ "tags": [
+ "blobs"
+ ],
+ "summary": "Return the status and representation headers for one Blob cell.",
+ "description": "This is a distinct handler rather than Axum's automatic GET-to-HEAD\nfallback. It never calls `BlobReader::read_range`; Range and If-Range are\ndeliberately ignored while If-None-Match is still evaluated.",
+ "operationId": "cluster_headBlob",
+ "parameters": [
+ {
+ "name": "graph_id",
+ "in": "path",
+ "description": "Graph id to route the request to.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "entity",
+ "in": "query",
+ "description": "Select a logical node or edge cell.",
+ "required": true,
+ "schema": {
+ "$ref": "#/components/schemas/BlobEntityKind"
+ }
+ },
+ {
+ "name": "type",
+ "in": "query",
+ "description": "Accepted-schema node or edge type name.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "id",
+ "in": "query",
+ "description": "Logical entity id within the selected type.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "property",
+ "in": "query",
+ "description": "Accepted-schema Blob property name.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "branch",
+ "in": "query",
+ "description": "Branch to read. Mutually exclusive with `snapshot`; defaults to `main`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "snapshot",
+ "in": "query",
+ "description": "Immutable graph snapshot id. Mutually exclusive with `branch`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "If-Match",
+ "in": "header",
+ "description": "Strong entity-tag-list precondition, including `*`, evaluated before If-None-Match.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "If-None-Match",
+ "in": "header",
+ "description": "Weak entity-tag-list comparison, including `*`. Range and If-Range are ignored for HEAD.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "Range",
+ "in": "header",
+ "description": "Accepted but ignored for HEAD; metadata always describes the complete selected Blob.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ {
+ "name": "If-Range",
+ "in": "header",
+ "description": "Accepted but ignored for HEAD together with Range.",
+ "required": false,
+ "schema": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Managed Blob metadata with no response body",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "The literal value `bytes`"
+ },
+ "Content-Length": {
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "description": "Complete managed Blob length"
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Strong validator for the selected managed Blob"
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Exact resolved graph snapshot"
+ }
+ }
+ },
+ "302": {
+ "description": "External Blob descriptor; the server does not dereference it",
+ "headers": {
+ "Cache-Control": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "The literal value `no-store`"
+ },
+ "Location": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Exact stored absolute URI"
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ },
+ "description": "Exact resolved graph snapshot"
+ }
+ }
+ },
+ "304": {
+ "description": "If-None-Match matched the managed Blob validator",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Content-Length": {
+ "schema": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "description": "Complete managed Blob length, as required for a valid 304 Content-Length"
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid selector, target, or non-Blob property; HEAD responses have no body"
+ },
+ "401": {
+ "description": "Unauthorized; HEAD responses have no body"
+ },
+ "403": {
+ "description": "Forbidden; HEAD responses have no body"
+ },
+ "404": {
+ "description": "Unknown entity or null Blob cell; HEAD responses have no body"
+ },
+ "412": {
+ "description": "If-Match did not strongly match the selected managed Blob validator; HEAD responses have no body",
+ "headers": {
+ "Accept-Ranges": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "ETag": {
+ "schema": {
+ "type": "string"
+ }
+ },
+ "Omnigraph-Snapshot-Id": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Stored Blob integrity or pre-header delivery refusal, including ranged external descriptors that cannot be redirected; HEAD responses have no body"
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ }
+ },
+ "/graphs/{graph_id}/branches": {
+ "get": {
+ "tags": [
+ "branches"
+ ],
+ "summary": "List all branches.",
+ "description": "Returns branch names sorted alphabetically. Read-only.",
+ "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",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BranchListOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ },
+ "post": {
+ "tags": [
+ "branches"
+ ],
+ "summary": "Create a new branch.",
+ "description": "Forks `name` off of `from` (defaults to `main`). The new branch shares\nbacking dataset data with its parent until it is mutated. Returns 409 if `name`\nalready exists.",
+ "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": {
+ "schema": {
+ "$ref": "#/components/schemas/BranchCreateRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Branch created",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BranchCreateOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Branch already exists",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "An overlapping durable recovery intent must be resolved before retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ }
+ },
+ "/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.\n\nWith `delete_branch: true` the source branch is deleted after a successful\nmerge, under its own `branch_delete` policy check. The merge is durable by\nthen, so a deletion refusal or failure never fails the request; it is\nreported via `branch_deleted: false` + `branch_delete_error`.",
+ "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": {
+ "schema": {
+ "$ref": "#/components/schemas/BranchMergeRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Branches merged",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BranchMergeOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Merge conflict",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "Merge entity, byte, or recovery-chain ceiling exceeded before effects",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "A merge could not probe or read an allowed external Blob source",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "An overlapping durable recovery intent must be resolved before retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ }
+ },
+ "/graphs/{graph_id}/branches/{branch}": {
+ "delete": {
"tags": [
"branches"
],
- "summary": "List all branches.",
- "description": "Returns branch names sorted alphabetically. Read-only.",
- "operationId": "cluster_listBranches",
+ "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": "cluster_deleteBranch",
"parameters": [
{
"name": "graph_id",
@@ -84,15 +993,24 @@
"schema": {
"type": "string"
}
+ },
+ {
+ "name": "branch",
+ "in": "path",
+ "description": "Branch name to delete",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
}
],
"responses": {
"200": {
- "description": "List of branches",
+ "description": "Branch deleted",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/BranchListOutput"
+ "$ref": "#/components/schemas/BranchDeleteOutput"
}
}
}
@@ -116,6 +1034,36 @@
}
}
}
+ },
+ "404": {
+ "description": "Branch not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "An overlapping durable recovery intent must be resolved before retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
}
},
"security": [
@@ -123,52 +1071,251 @@
"bearer_token": []
}
]
- },
+ }
+ },
+ "/graphs/{graph_id}/change": {
"post": {
"tags": [
- "branches"
+ "mutations"
],
- "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": "cluster_createBranch",
+ "summary": "**Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.",
+ "description": "Apply a GQ mutation to a branch. The deprecated route retains its request\nand execution semantics, while its response uses the current canonical\nvocabulary. New integrations should target `POST /mutate`. Responses 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": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Write-authority conflict",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "Keyed write exceeds the per-commit entity or byte ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "An allowed external Blob source could not be probed or read",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "An overlapping durable recovery intent must be resolved before retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "deprecated": true,
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ }
+ },
+ "/graphs/{graph_id}/changes": {
+ "get": {
+ "tags": [
+ "changes"
+ ],
+ "summary": "Poll the change feed of one branch.",
+ "description": "At-least-once: retrying a cursor may replay the complete next commit, so\nconsumers apply blocks idempotently by `graph_commit_id` and persist the\nterminal cursor together with its blocks. The server holds no consumer\nstate.",
+ "operationId": "cluster_pollChanges",
"parameters": [
{
- "name": "graph_id",
- "in": "path",
- "description": "Graph id to route the request to.",
- "required": true,
+ "name": "graph_id",
+ "in": "path",
+ "description": "Graph id to route the request to.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "branch",
+ "in": "query",
+ "description": "Branch whose first-parent history is polled. Defaults to `main`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "cursor",
+ "in": "query",
+ "description": "Durable cursor from a prior terminal page. Mutually exclusive with\n`start` and `page_token`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "start",
+ "in": "query",
+ "description": "Explicit start mode: `now` (default) | `beginning` |\n`after:`. Mutually exclusive with `cursor` and `page_token`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "page_token",
+ "in": "query",
+ "description": "Continuation of one bounded poll (keeps its captured cut). Mutually\nexclusive with `cursor` and `start`.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "kind",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ }
+ }
+ },
+ {
+ "name": "type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "name": "op",
+ "in": "query",
+ "required": false,
"schema": {
- "type": "string"
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChangeOpOutput"
+ }
}
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/BranchCreateRequest"
- }
- }
- },
- "required": true
- },
"responses": {
"200": {
- "description": "Branch created",
+ "description": "Change blocks in first-parent order. The durable cursor appears only on a terminal page, advanced only over complete commits; a mid-block page carries only next_page_token",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/BranchCreateOutput"
+ "$ref": "#/components/schemas/ChangeFeedOutput"
}
}
}
},
"400": {
- "description": "Bad request",
+ "description": "Invalid start/filter combination, or a rejected cursor or page token",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -178,7 +1325,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -188,37 +1335,67 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Branch not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
"409": {
- "description": "Branch already exists",
+ "description": "The feed crossed an unprovable schema boundary; see change_diff_refusal",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "429": {
- "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "410": {
+ "description": "Feed gap: required history was reclaimed; reset via the baseline handshake",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "Requested limit exceeds the public change ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeErrorOutput"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal failure while reading changes",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
"503": {
- "description": "An overlapping durable recovery intent must be resolved before retry",
+ "description": "Recovery required before changes can be read",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -231,14 +1408,14 @@
]
}
},
- "/graphs/{graph_id}/branches/merge": {
+ "/graphs/{graph_id}/changes/baseline": {
"post": {
"tags": [
- "branches"
+ "changes"
],
- "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.\n\nWith `delete_branch: true` the source branch is deleted after a successful\nmerge, under its own `branch_delete` policy check. The merge is durable by\nthen, so a deletion refusal or failure never fails the request; it is\nreported via `branch_deleted: false` + `branch_delete_error`.",
- "operationId": "cluster_mergeBranches",
+ "summary": "Capture a change-feed baseline: one exact entity snapshot plus the cursor\nthat resumes the feed immediately after it.",
+ "description": "A baseline is a full data export, so it requires the export action. The\nsnapshot honors the scope's kind and type dimensions; `op` binds only the\nresume cursor's feed scope.",
+ "operationId": "cluster_captureChangeBaseline",
"parameters": [
{
"name": "graph_id",
@@ -254,7 +1431,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/BranchMergeRequest"
+ "$ref": "#/components/schemas/ChangeBaselineRequest"
}
}
},
@@ -262,21 +1439,21 @@
},
"responses": {
"200": {
- "description": "Branches merged",
+ "description": "NDJSON entity snapshot pinned at one captured commit. Every preceding record is one type-keyed entity record (the load/export NDJSON shape); the FINAL record is the ChangeBaselineRecord envelope — an interrupted stream has no terminal record and therefore no usable cursor. Install the snapshot durably before the cursor.",
"content": {
- "application/json": {
+ "application/x-ndjson": {
"schema": {
- "$ref": "#/components/schemas/BranchMergeOutput"
+ "$ref": "#/components/schemas/ChangeBaselineRecord"
}
}
}
},
"400": {
- "description": "Bad request",
+ "description": "Invalid scope",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -286,7 +1463,7 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -296,47 +1473,47 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "409": {
- "description": "Merge conflict",
+ "404": {
+ "description": "Branch not found",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
"413": {
- "description": "Merge row, byte, or recovery-chain ceiling exceeded before effects",
+ "description": "Baseline cut or transport capacity exhausted",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "429": {
- "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "500": {
+ "description": "Internal failure while capturing the baseline",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
"503": {
- "description": "An overlapping durable recovery intent must be resolved before retry",
+ "description": "Recovery required",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -349,14 +1526,14 @@
]
}
},
- "/graphs/{graph_id}/branches/{branch}": {
- "delete": {
+ "/graphs/{graph_id}/commits": {
+ "get": {
"tags": [
- "branches"
+ "commits"
],
- "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": "cluster_deleteBranch",
+ "summary": "List commits, most recent first.",
+ "description": "`branch` selects which history to list: a named branch returns the history\nreachable from that branch's head (the main commits inherited up to the\nfork plus the branch-authored commits); omitting it returns `main`'s\nhistory. There is no cross-branch listing. Ordering is part of the\ncontract — newest first by (graph-manifest version, created-at, commit id) — and\na future `cursor`/`limit` pagination will be keyset-based on that same\norder. Read-only.",
+ "operationId": "cluster_listCommits",
"parameters": [
{
"name": "graph_id",
@@ -369,21 +1546,23 @@
},
{
"name": "branch",
- "in": "path",
- "description": "Branch name to delete",
- "required": true,
+ "in": "query",
+ "required": false,
"schema": {
- "type": "string"
+ "type": [
+ "string",
+ "null"
+ ]
}
}
],
"responses": {
"200": {
- "description": "Branch deleted",
+ "description": "List of commits",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/BranchDeleteOutput"
+ "$ref": "#/components/schemas/CommitListOutput"
}
}
}
@@ -407,36 +1586,6 @@
}
}
}
- },
- "404": {
- "description": "Branch not found",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
- },
- "429": {
- "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
- },
- "503": {
- "description": "An overlapping durable recovery intent must be resolved before retry",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
}
},
"security": [
@@ -446,14 +1595,14 @@
]
}
},
- "/graphs/{graph_id}/change": {
- "post": {
+ "/graphs/{graph_id}/commits/{commit_id}": {
+ "get": {
"tags": [
- "mutations"
+ "commits"
],
- "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": "cluster_change",
+ "summary": "Get a single commit.",
+ "description": "Returns the commit's graph-manifest version, parent commit(s), and creation\nmetadata. Read-only.",
+ "operationId": "cluster_getCommit",
"parameters": [
{
"name": "graph_id",
@@ -463,35 +1612,24 @@
"schema": {
"type": "string"
}
+ },
+ {
+ "name": "commit_id",
+ "in": "path",
+ "description": "Commit identifier",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
}
],
- "requestBody": {
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ChangeRequest"
- }
- }
- },
- "required": true
- },
"responses": {
"200": {
- "description": "Mutation results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ChangeOutput"
- }
- }
- }
- },
- "400": {
- "description": "Bad request",
+ "description": "Commit details",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/CommitOutput"
}
}
}
@@ -516,38 +1654,8 @@
}
}
},
- "409": {
- "description": "Write-authority conflict",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
- },
- "413": {
- "description": "Keyed write exceeds the per-commit row or byte ceiling",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
- },
- "429": {
- "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
- },
- "503": {
- "description": "An overlapping durable recovery intent must be resolved before retry",
+ "404": {
+ "description": "Commit not found",
"content": {
"application/json": {
"schema": {
@@ -557,7 +1665,6 @@
}
}
},
- "deprecated": true,
"security": [
{
"bearer_token": []
@@ -565,14 +1672,14 @@
]
}
},
- "/graphs/{graph_id}/commits": {
+ "/graphs/{graph_id}/commits/{commit_id}/changes": {
"get": {
"tags": [
- "commits"
+ "changes"
],
- "summary": "List commits, most recent first.",
- "description": "`branch` selects which history to list: a named branch returns the history\nreachable from that branch's head (the main commits inherited up to the\nfork plus the branch-authored commits); omitting it returns `main`'s\nhistory. There is no cross-branch listing. Ordering is part of the\ncontract — newest first by (manifest version, created-at, commit id) — and\na future `cursor`/`limit` pagination will be keyset-based on that same\norder. Read-only.",
- "operationId": "cluster_listCommits",
+ "summary": "Entity changes one commit made relative to its first parent.",
+ "description": "Read-only, in graph vocabulary with exact before/after images. Bounded:\na large commit continues via the opaque `page_token`.",
+ "operationId": "cluster_getCommitChanges",
"parameters": [
{
"name": "graph_id",
@@ -580,28 +1687,91 @@
"description": "Graph id to route the request to.",
"required": true,
"schema": {
- "type": "string"
+ "type": "string"
+ }
+ },
+ {
+ "name": "commit_id",
+ "in": "path",
+ "description": "Commit identifier",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "page_token",
+ "in": "query",
+ "description": "Opaque continuation from the preceding page of this response.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "description": "Maximum changes per page. Server default applies when absent; above\nthe public ceiling the request fails with 413.",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "minimum": 0
+ }
+ },
+ {
+ "name": "kind",
+ "in": "query",
+ "description": "Repeatable filter: node | edge.",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ }
+ }
+ },
+ {
+ "name": "type",
+ "in": "query",
+ "description": "Repeatable filter: accepted-schema type name.",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
}
},
{
- "name": "branch",
+ "name": "op",
"in": "query",
+ "description": "Repeatable filter: insert | update | delete.",
"required": false,
"schema": {
- "type": [
- "string",
- "null"
- ]
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChangeOpOutput"
+ }
}
}
],
"responses": {
"200": {
- "description": "List of commits",
+ "description": "Entity changes this commit made relative to its first parent, in frozen (kind, type, id, op) order with the cause stated once",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CommitListOutput"
+ "$ref": "#/components/schemas/CommitChangesOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid filter or limit, or a rejected page token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -611,94 +1781,67 @@
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "403": {
- "description": "Forbidden",
+ "404": {
+ "description": "Commit not found, or the actor cannot read the commit's branch",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
- }
- },
- "security": [
- {
- "bearer_token": []
- }
- ]
- }
- },
- "/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": "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",
- "description": "Commit identifier",
- "required": true,
- "schema": {
- "type": "string"
+ "409": {
+ "description": "Commit cannot be entity-diffed (parentless commit or schema boundary); see change_diff_refusal",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeErrorOutput"
+ }
+ }
}
- }
- ],
- "responses": {
- "200": {
- "description": "Commit details",
+ },
+ "410": {
+ "description": "Required retained history is no longer readable; see change_feed_gap and capture a new baseline",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/CommitOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "401": {
- "description": "Unauthorized",
+ "413": {
+ "description": "Requested limit exceeds the public change ceiling",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "403": {
- "description": "Forbidden",
+ "500": {
+ "description": "Internal failure while reading changes",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
},
- "404": {
- "description": "Commit not found",
+ "503": {
+ "description": "Recovery required before changes can be read",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ErrorOutput"
+ "$ref": "#/components/schemas/ChangeErrorOutput"
}
}
}
@@ -717,7 +1860,7 @@
"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.",
+ "description": "Emits one JSON object per line (`application/x-ndjson`). Filter with\n`type_names` (node/edge type names); an empty list streams the entire branch.\nSuitable for large exports — the response is streamed, not buffered.\nRead-only.",
"operationId": "cluster_export",
"parameters": [
{
@@ -807,6 +1950,16 @@
}
}
},
+ "415": {
+ "description": "Request body must use application/json",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
"503": {
"description": "Recovery required",
"content": {
@@ -831,7 +1984,7 @@
"mutations"
],
"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.",
+ "description": "Bulk-load NDJSON data into a branch. The deprecated route retains its\nparser and branch defaults, but its response uses the current canonical\nvocabulary. New integrations should target `POST /load`. Responses\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": [
{
@@ -906,7 +2059,17 @@
}
},
"413": {
- "description": "Keyed load exceeds the per-commit row or byte ceiling",
+ "description": "Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "An allowed external Blob source could not be probed or read",
"content": {
"application/json": {
"schema": {
@@ -950,7 +2113,7 @@
"mutations"
],
"summary": "Compatibility-load NDJSON data through a JSON envelope.",
- "description": "`data` is NDJSON with one record per line. `mode` controls behavior on\nexisting rows: `merge` upserts by id (default), `append` strictly inserts\nabsent ids, and `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.",
+ "description": "`data` is NDJSON with one record per line. `mode` controls behavior on\nexisting entities: `merge` upserts by id (default), `append` strictly inserts\nabsent ids, and `overwrite` replaces type data. 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": [
{
@@ -1025,7 +2188,17 @@
}
},
"413": {
- "description": "Keyed load exceeds the per-commit row or byte ceiling",
+ "description": "Load input or external Blob admission exceeds a bounded per-operation entity or byte ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "An allowed external Blob source could not be probed or read",
"content": {
"application/json": {
"schema": {
@@ -1107,26 +2280,174 @@
{
"name": "mode",
"in": "query",
- "description": "How existing rows are handled. Defaults to `merge`.",
+ "description": "How existing entities are handled. Defaults to `merge`.",
"required": false,
"schema": {
- "oneOf": [
- {
- "type": "null"
- },
- {
- "$ref": "#/components/schemas/LoadMode"
- }
- ]
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/LoadMode"
+ }
+ ]
+ }
+ }
+ ],
+ "requestBody": {
+ "description": "Strict raw graph-level NDJSON. Each nonblank line is exactly one node envelope {\"type\":\"\",\"data\":{...}} or edge envelope {\"edge\":\"\",\"from\":\"\",\"to\":\"\",\"data\":{...}}. `data` defaults to {}; optional `data.id` follows ordinary ID semantics. Duplicate, unknown, reserved physical, and noncanonical supplied node-ID members are refused.",
+ "content": {
+ "application/x-ndjson": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "description": "One committed graph-batch result",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GraphBatchLoadOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Malformed query or graph batch",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Target branch missing without `from`",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Prepared load authority changed before effects",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "Request, load, or external Blob admission exceeds a bounded ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "415": {
+ "description": "Content-Type must be application/x-ndjson",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "An allowed external Blob source could not be probed or read",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "An overlapping durable recovery intent must be resolved before retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ }
+ },
+ "/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 when the prepared\nwrite authority changes before effects.\n\nConditional callers use `POST /mutate/if-graph-commit`. Keeping that\ncapability on a distinct path makes rolling upgrades fail closed: an older\nserver returns 404 instead of ignoring an unknown optional header and\nmutating unconditionally.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.",
+ "operationId": "cluster_mutate",
+ "parameters": [
+ {
+ "name": "graph_id",
+ "in": "path",
+ "description": "Graph id to route the request to.",
+ "required": true,
+ "schema": {
+ "type": "string"
}
}
],
"requestBody": {
- "description": "Strict raw graph-level NDJSON. Each nonblank line is exactly one node envelope {\"type\":\"\",\"data\":{...}} or edge envelope {\"edge\":\"\",\"from\":\"\",\"to\":\"\",\"data\":{...}}. `data` defaults to {}; optional `data.id` follows ordinary ID semantics. Duplicate, unknown, reserved physical, and noncanonical supplied node-ID members are refused.",
"content": {
- "application/x-ndjson": {
+ "application/json": {
"schema": {
- "type": "string"
+ "$ref": "#/components/schemas/ChangeRequest"
}
}
},
@@ -1134,17 +2455,17 @@
},
"responses": {
"200": {
- "description": "One committed graph-batch result",
+ "description": "Mutation results",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/GraphBatchLoadOutput"
+ "$ref": "#/components/schemas/ChangeOutput"
}
}
}
},
"400": {
- "description": "Malformed query or graph batch",
+ "description": "Bad request",
"content": {
"application/json": {
"schema": {
@@ -1173,18 +2494,8 @@
}
}
},
- "404": {
- "description": "Target branch missing without `from`",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "#/components/schemas/ErrorOutput"
- }
- }
- }
- },
"409": {
- "description": "Prepared load authority changed before effects",
+ "description": "Write-authority conflict",
"content": {
"application/json": {
"schema": {
@@ -1194,7 +2505,7 @@
}
},
"413": {
- "description": "Request or keyed load exceeds a bounded ceiling",
+ "description": "Keyed write exceeds the per-commit entity or byte ceiling",
"content": {
"application/json": {
"schema": {
@@ -1203,8 +2514,8 @@
}
}
},
- "415": {
- "description": "Content-Type must be application/x-ndjson",
+ "424": {
+ "description": "An allowed external Blob source could not be probed or read",
"content": {
"application/json": {
"schema": {
@@ -1241,14 +2552,14 @@
]
}
},
- "/graphs/{graph_id}/mutate": {
+ "/graphs/{graph_id}/mutate/if-graph-commit": {
"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 when the prepared\nwrite authority changes before effects.\n\nPairs with `POST /query` (read-only). The legacy `POST /change` route\nhas identical semantics and is kept as a deprecated alias.",
- "operationId": "cluster_mutate",
+ "summary": "Apply a mutation only while the branch still has the required graph head.",
+ "description": "The dedicated path is the rolling-safe capability signal. Clients must not\nsend this header to `/mutate`: an older server could ignore an unknown\noptional header after executing the write.",
+ "operationId": "cluster_mutate_if_graph_commit",
"parameters": [
{
"name": "graph_id",
@@ -1258,6 +2569,15 @@
"schema": {
"type": "string"
}
+ },
+ {
+ "name": "Omnigraph-If-Graph-Commit",
+ "in": "header",
+ "description": "Required raw graph-head commit id. The mutation runs only while the branch's effective head still equals it.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
}
],
"requestBody": {
@@ -1272,7 +2592,7 @@
},
"responses": {
"200": {
- "description": "Mutation results",
+ "description": "Conditional mutation results",
"content": {
"application/json": {
"schema": {
@@ -1282,7 +2602,7 @@
}
},
"400": {
- "description": "Bad request",
+ "description": "Missing, duplicate, malformed, or invalid request",
"content": {
"application/json": {
"schema": {
@@ -1321,8 +2641,28 @@
}
}
},
+ "412": {
+ "description": "Graph-commit precondition failed; the write had no effect",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
"413": {
- "description": "Keyed write exceeds the per-commit row or byte ceiling",
+ "description": "Keyed write exceeds the per-commit entity or byte ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "An allowed external Blob source could not be probed or read",
"content": {
"application/json": {
"schema": {
@@ -1512,6 +2852,178 @@
}
}
},
+ "409": {
+ "description": "Stored mutation write-authority conflict, or a full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "Stored keyed mutation exceeds the per-commit entity or byte ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "A stored mutation could not probe or read an allowed external Blob source",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "429": {
+ "description": "Per-actor admission cap exceeded; honor `Retry-After` header",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Policy evaluation error (a denial is reported as 404, not 500)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "A stored mutation is blocked by a durable recovery intent",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "bearer_token": []
+ }
+ ]
+ }
+ },
+ "/graphs/{graph_id}/queries/{name}/if-graph-commit": {
+ "post": {
+ "tags": [
+ "queries"
+ ],
+ "summary": "Invoke one stored mutation with a required graph-head precondition.",
+ "description": "A distinct path makes support observable before any mutation runs; older\nservers return 404 instead of ignoring an unknown conditional header.",
+ "operationId": "cluster_invoke_query_if_graph_commit",
+ "parameters": [
+ {
+ "name": "graph_id",
+ "in": "path",
+ "description": "Graph id to route the request to.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "description": "Stored mutation name (the registry key)",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "Omnigraph-If-Graph-Commit",
+ "in": "header",
+ "description": "Required raw graph-head commit id. The stored mutation runs only while the branch's effective head still equals it.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/InvokeStoredQueryRequest"
+ }
+ ]
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Stored conditional mutation result",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChangeOutput"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Missing, duplicate, malformed, read-only, or invalid invocation",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Forbidden (the inner `change` gate)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Unknown stored mutation, or `invoke_query` denied",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
"409": {
"description": "Stored mutation write-authority conflict",
"content": {
@@ -1522,8 +3034,28 @@
}
}
},
+ "412": {
+ "description": "Stored mutation graph-commit precondition failed; the write had no effect",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
"413": {
- "description": "Stored keyed mutation exceeds the per-commit row or byte ceiling",
+ "description": "Stored keyed mutation exceeds the per-commit entity or byte ceiling",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
+ },
+ "424": {
+ "description": "A stored mutation could not probe or read an allowed external Blob source",
"content": {
"application/json": {
"schema": {
@@ -1576,7 +3108,7 @@
"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.",
+ "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. It shares `POST /read` target semantics (branch xor\nsnapshot) and the same Cedar action (Read), while its canonical response\nadditionally carries the pinned graph-commit token.",
"operationId": "cluster_query",
"parameters": [
{
@@ -1639,6 +3171,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
}
},
"security": [
@@ -1679,11 +3221,11 @@
},
"responses": {
"200": {
- "description": "Query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)",
+ "description": "Legacy token-free query results (response includes `Deprecation: true` + `Link: ; rel=\"successor-version\"`)",
"content": {
"application/json": {
"schema": {
- "$ref": "#/components/schemas/ReadOutput"
+ "$ref": "#/components/schemas/LegacyReadOutput"
}
}
}
@@ -1717,6 +3259,16 @@
}
}
}
+ },
+ "409": {
+ "description": "Full-text index requires explicit rebuilding; full_text_index_rebuild_required is not cleared by retrying",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorOutput"
+ }
+ }
+ }
}
},
"deprecated": true,
@@ -1733,7 +3285,7 @@
"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.",
+ "description": "Returns the project's schema as a single string in `.pg` source form.\nUseful for clients that want to introspect available types and properties\nbefore constructing GQ queries. Read-only.",
"operationId": "cluster_getSchema",
"parameters": [
{
@@ -1791,7 +3343,7 @@
"mutations"
],
"summary": "Apply a schema migration.",
- "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.",
+ "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 property, 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": [
{
@@ -1889,7 +3441,7 @@
"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.",
+ "description": "Returns the graph-manifest version plus per-dataset metadata (path,\npublished dataset version, entity count) for every backing dataset on the\nbranch. Defaults to `main` when `branch` is omitted. Read-only.",
"operationId": "cluster_getSnapshot",
"parameters": [
{
@@ -1915,7 +3467,7 @@
],
"responses": {
"200": {
- "description": "Database snapshot",
+ "description": "Graph snapshot",
"content": {
"application/json": {
"schema": {
@@ -1972,160 +3524,515 @@
}
}
}
- }
- }
- },
- "components": {
- "schemas": {
- "BranchCreateOutput": {
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "BlobEntityKind": {
+ "type": "string",
+ "description": "Logical graph entity selected by the Blob delivery surface.\n\nThis is intentionally graph vocabulary: callers select a node or edge.",
+ "enum": [
+ "node",
+ "edge"
+ ]
+ },
+ "BlobRangeOutput": {
+ "type": "object",
+ "description": "Normalized half-open range details for an unsatisfiable managed Blob read.\n\nHTTP also returns `Content-Range: bytes */N`; these fields let SDKs inspect\nthe failure without parsing either that header or the human-readable text.",
+ "required": [
+ "start",
+ "end",
+ "length"
+ ],
+ "properties": {
+ "end": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "length": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "start": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ }
+ }
+ },
+ "BranchCreateOutput": {
+ "type": "object",
+ "required": [
+ "uri",
+ "from",
+ "name"
+ ],
+ "properties": {
+ "actor_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "from": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "uri": {
+ "type": "string"
+ }
+ }
+ },
+ "BranchCreateRequest": {
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "from": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Parent branch to fork from. Defaults to `main`."
+ },
+ "name": {
+ "type": "string",
+ "description": "Name of the new branch. Must not already exist."
+ }
+ }
+ },
+ "BranchDeleteOutput": {
+ "type": "object",
+ "required": [
+ "uri",
+ "name"
+ ],
+ "properties": {
+ "actor_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "name": {
+ "type": "string"
+ },
+ "uri": {
+ "type": "string"
+ }
+ }
+ },
+ "BranchListOutput": {
+ "type": "object",
+ "required": [
+ "branches"
+ ],
+ "properties": {
+ "branches": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "BranchMergeOutcome": {
+ "type": "string",
+ "enum": [
+ "already_up_to_date",
+ "fast_forward",
+ "merged"
+ ]
+ },
+ "BranchMergeOutput": {
+ "type": "object",
+ "required": [
+ "source",
+ "target",
+ "outcome"
+ ],
+ "properties": {
+ "actor_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "branch_delete_error": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Why the requested source-branch deletion did not happen. Present iff\n`branch_deleted` is `false`."
+ },
+ "branch_deleted": {
+ "type": [
+ "boolean",
+ "null"
+ ],
+ "description": "Result of the requested post-merge source-branch deletion. Absent when\n`delete_branch` was not requested; `true` when the source branch was\ndeleted; `false` when the deletion was refused or failed (the merge\nitself still succeeded — see `branch_delete_error`)."
+ },
+ "outcome": {
+ "$ref": "#/components/schemas/BranchMergeOutcome"
+ },
+ "source": {
+ "type": "string"
+ },
+ "target": {
+ "type": "string"
+ }
+ }
+ },
+ "BranchMergeRequest": {
+ "type": "object",
+ "required": [
+ "source"
+ ],
+ "properties": {
+ "delete_branch": {
+ "type": "boolean",
+ "description": "Delete the source branch after a successful merge. The deletion runs\nunder its own `branch_delete` policy check; a refusal or failure is\nreported via `branch_deleted` / `branch_delete_error` on the response\nand never fails the already-landed merge."
+ },
+ "source": {
+ "type": "string",
+ "description": "Source branch whose commits will be merged."
+ },
+ "target": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Target branch that will receive the merge. Defaults to `main`."
+ }
+ }
+ },
+ "ChangeBaselineOutput": {
+ "type": "object",
+ "description": "Terminal payload of a baseline stream: the captured snapshot commit and\nthe cursor that resumes the feed immediately after it.",
+ "required": [
+ "snapshot_commit_id",
+ "resume_cursor"
+ ],
+ "properties": {
+ "resume_cursor": {
+ "type": "string"
+ },
+ "snapshot_commit_id": {
+ "type": "string"
+ }
+ }
+ },
+ "ChangeBaselineRecord": {
+ "type": "object",
+ "description": "Wire envelope of the FINAL baseline stream line: `{\"baseline\": {...}}`,\ndistinguishable from snapshot records (which carry `type`/`edge` keys).\nEmitted exactly once, only after every snapshot record — an interrupted\nstream has no terminal record and therefore no usable cursor.",
+ "required": [
+ "baseline"
+ ],
+ "properties": {
+ "baseline": {
+ "$ref": "#/components/schemas/ChangeBaselineOutput"
+ }
+ }
+ },
+ "ChangeBaselineRequest": {
+ "type": "object",
+ "description": "Body for the change baseline handshake.",
+ "properties": {
+ "branch": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Branch to capture. Defaults to `main`."
+ },
+ "kind": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ },
+ "description": "Feed scope the resume cursor is bound to. The snapshot honors `kind`\nand `type`; `op` constrains only subsequent polls."
+ },
+ "op": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChangeOpOutput"
+ }
+ },
+ "type": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "ChangeBlockOutput": {
+ "type": "object",
+ "description": "One commit block inside a feed page.",
+ "required": [
+ "cause",
+ "changes"
+ ],
+ "properties": {
+ "cause": {
+ "$ref": "#/components/schemas/ChangeCauseOutput"
+ },
+ "changes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EntityChangeOutput"
+ }
+ }
+ }
+ },
+ "ChangeCauseOutput": {
+ "type": "object",
+ "description": "The commit cause of one change block, stated once.",
+ "required": [
+ "graph_commit_id",
+ "authored_branch",
+ "authored_at"
+ ],
+ "properties": {
+ "actor_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "authored_at": {
+ "type": "integer",
+ "format": "int64",
+ "description": "Authorship time as Unix epoch microseconds — minted before dataset\neffects and stable across retries; deliberately not labeled a commit or\npublication time.",
+ "example": 1714000000000000
+ },
+ "authored_branch": {
+ "type": "string",
+ "description": "The branch the commit originally landed on (not the requested branch)."
+ },
+ "graph_commit_id": {
+ "type": "string"
+ },
+ "merged_parent_commit_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "parent_commit_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "ChangeDiffRefusalOutput": {
"type": "object",
+ "description": "A well-formed entity-diff request this commit cannot satisfy (HTTP 409).",
"required": [
- "uri",
- "from",
- "name"
+ "reason",
+ "graph_commit_id"
],
"properties": {
- "actor_id": {
+ "graph_commit_id": {
+ "type": "string"
+ },
+ "reason": {
+ "$ref": "#/components/schemas/ChangeDiffRefusalReason"
+ },
+ "type_name": {
"type": [
"string",
"null"
- ]
- },
+ ],
+ "description": "The graph type at the schema boundary, when the reason names one."
+ }
+ }
+ },
+ "ChangeDiffRefusalReason": {
+ "type": "string",
+ "description": "Why a well-formed entity-diff request was refused (HTTP 409).",
+ "enum": [
+ "parentless_commit",
+ "schema_boundary",
+ "unknown"
+ ]
+ },
+ "ChangeEndpointsOutput": {
+ "type": "object",
+ "description": "Edge endpoints as graph references. Endpoints belong to each image, so an\nendpoint-moving update has distinct before and after endpoints.",
+ "required": [
+ "from",
+ "to"
+ ],
+ "properties": {
"from": {
"type": "string"
},
- "name": {
- "type": "string"
- },
- "uri": {
+ "to": {
"type": "string"
}
}
},
- "BranchCreateRequest": {
+ "ChangeErrorOutput": {
"type": "object",
+ "description": "Error envelope for the read-only change surfaces (`…/changes`,\n`…/changes/baseline`, `…/commits/{commit_id}/changes`): a wire-compatible\nprojection of [`ErrorOutput`] restricted to the graph-vocabulary details\nthose routes can produce after their error projection. The write-path\nconflict shapes (key / published-dataset-version / merge / read-set) are\nstructurally absent because change routes cannot produce them. Servers\nserialize [`ErrorOutput`]; every field a change route can populate appears\nhere with the same name and meaning, and absent optionals are wire-compatible.",
"required": [
- "name"
+ "error"
],
"properties": {
- "from": {
- "type": [
- "string",
- "null"
- ],
- "description": "Parent branch to fork from. Defaults to `main`."
+ "change_diff_refusal": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeDiffRefusalOutput",
+ "description": "Set with HTTP 409 when a commit entity diff is refused (parentless\ncommit or an unprovable schema boundary)."
+ }
+ ]
},
- "name": {
- "type": "string",
- "description": "Name of the new branch. Must not already exist."
+ "change_feed_gap": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeFeedGapOutput",
+ "description": "Set with HTTP 410 when retained history can no longer reconstruct a\nchange continuation. Recover via the baseline handshake."
+ }
+ ]
+ },
+ "code": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ErrorCode"
+ }
+ ]
+ },
+ "error": {
+ "type": "string"
+ },
+ "recovery_required": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/RecoveryRequiredOutput",
+ "description": "Set with HTTP 503 when the graph has a durable recovery intent that\nmust be resolved before the requested change read can proceed."
+ }
+ ]
+ },
+ "resource_limit": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ResourceLimitOutput",
+ "description": "Set when a requested limit exceeds a public ceiling, or a single change\nexceeds the poll's own byte ceiling."
+ }
+ ]
}
}
},
- "BranchDeleteOutput": {
+ "ChangeFeedGapOutput": {
"type": "object",
+ "description": "A change continuation can no longer be reconstructed from retained history\n(HTTP 410). Recovery is the baseline handshake; retrying the same cursor\ncannot succeed. `code` stays unset: [`ErrorCode`] is closed and this\nadditive detail is the machine-readable discriminator (the same rolling\ncontract as `external_blob_source`).",
"required": [
- "uri",
- "name"
+ "first_unreadable_commit_id"
],
"properties": {
- "actor_id": {
+ "cursor": {
"type": [
"string",
"null"
]
},
- "name": {
- "type": "string"
- },
- "uri": {
+ "first_unreadable_commit_id": {
"type": "string"
}
}
},
- "BranchListOutput": {
+ "ChangeFeedOutput": {
"type": "object",
+ "description": "One bounded feed poll result.",
"required": [
- "branches"
+ "blocks"
],
"properties": {
- "branches": {
+ "blocks": {
"type": "array",
"items": {
- "type": "string"
+ "$ref": "#/components/schemas/ChangeBlockOutput"
}
- }
- }
- },
- "BranchMergeOutcome": {
- "type": "string",
- "enum": [
- "already_up_to_date",
- "fast_forward",
- "merged"
- ]
- },
- "BranchMergeOutput": {
- "type": "object",
- "required": [
- "source",
- "target",
- "outcome"
- ],
- "properties": {
- "actor_id": {
+ },
+ "caught_up": {
"type": [
- "string",
+ "boolean",
"null"
- ]
+ ],
+ "description": "Present with `cursor` on a terminal page: true when the page reached\nits captured head, false when more complete commits already wait."
},
- "branch_delete_error": {
+ "cursor": {
"type": [
"string",
"null"
],
- "description": "Why the requested source-branch deletion did not happen. Present iff\n`branch_deleted` is `false`."
+ "description": "Durable caller-owned cursor, advanced only over complete commits and\nreturned only on a terminal page — an interrupted poll never advances\nit."
},
- "branch_deleted": {
+ "next_page_token": {
"type": [
- "boolean",
+ "string",
"null"
],
- "description": "Result of the requested post-merge source-branch deletion. Absent when\n`delete_branch` was not requested; `true` when the source branch was\ndeleted; `false` when the deletion was refused or failed (the merge\nitself still succeeded — see `branch_delete_error`)."
- },
- "outcome": {
- "$ref": "#/components/schemas/BranchMergeOutcome"
- },
- "source": {
- "type": "string"
- },
- "target": {
- "type": "string"
+ "description": "Continue this poll's captured cut. Absent on a terminal page."
}
}
},
- "BranchMergeRequest": {
+ "ChangeImageOutput": {
"type": "object",
+ "description": "One exact logical entity image, decoded with the commit-era schema.",
"required": [
- "source"
+ "properties"
],
"properties": {
- "delete_branch": {
- "type": "boolean",
- "description": "Delete the source branch after a successful merge. The deletion runs\nunder its own `branch_delete` policy check; a refusal or failure is\nreported via `branch_deleted` / `branch_delete_error` on the response\nand never fails the already-landed merge."
- },
- "source": {
- "type": "string",
- "description": "Source branch whose commits will be merged."
+ "endpoints": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeEndpointsOutput",
+ "description": "Present for edge images only."
+ }
+ ]
},
- "target": {
- "type": [
- "string",
- "null"
- ],
- "description": "Target branch that will receive the merge. Defaults to `main`."
+ "properties": {
+ "description": "Exact logical property values; user-schema keys verbatim."
}
}
},
+ "ChangeOpOutput": {
+ "type": "string",
+ "description": "Logical operation of one change. Ordering rank is frozen:\ninsert before update before delete within one entity.",
+ "enum": [
+ "insert",
+ "update",
+ "delete"
+ ]
+ },
"ChangeOutput": {
"type": "object",
"required": [
@@ -2152,6 +4059,16 @@
"branch": {
"type": "string"
},
+ "commit": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/CommitOutput"
+ }
+ ]
+ },
"query_name": {
"type": "string"
}
@@ -2187,6 +4104,48 @@
}
}
},
+ "ChangeTypeOutput": {
+ "type": "object",
+ "description": "Graph-scoped type identity. `id` is opaque: it survives a supported rename\nand changes after drop/re-add. It is not a type-name selector or path.",
+ "required": [
+ "id",
+ "name"
+ ],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
+ "CommitChangesOutput": {
+ "type": "object",
+ "description": "One bounded page of the finite commit entity diff.",
+ "required": [
+ "cause",
+ "changes"
+ ],
+ "properties": {
+ "cause": {
+ "$ref": "#/components/schemas/ChangeCauseOutput"
+ },
+ "changes": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/EntityChangeOutput"
+ }
+ },
+ "next_page_token": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Continue THIS bounded response. Absent on the final page. Never a feed\ncursor."
+ }
+ }
+ },
"CommitListOutput": {
"type": "object",
"required": [
@@ -2205,7 +4164,7 @@
"type": "object",
"required": [
"graph_commit_id",
- "manifest_version",
+ "graph_manifest_version",
"created_at"
],
"properties": {
@@ -2221,16 +4180,16 @@
"description": "Commit creation time as Unix epoch microseconds.",
"example": 1714000000000000
},
- "graph_commit_id": {
- "type": "string"
- },
- "manifest_branch": {
+ "graph_branch": {
"type": [
"string",
"null"
]
},
- "manifest_version": {
+ "graph_commit_id": {
+ "type": "string"
+ },
+ "graph_manifest_version": {
"type": "integer",
"format": "int64",
"minimum": 0
@@ -2249,6 +4208,58 @@
}
}
},
+ "EntityChangeOutput": {
+ "type": "object",
+ "description": "One entity change. Cause is stated once on the enclosing block, never here.\nAn insert carries only `after`, an update exact `before` and `after`, a\ndelete only `before`.",
+ "required": [
+ "kind",
+ "type",
+ "id",
+ "op"
+ ],
+ "properties": {
+ "after": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeImageOutput"
+ }
+ ]
+ },
+ "before": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeImageOutput"
+ }
+ ]
+ },
+ "id": {
+ "type": "string"
+ },
+ "kind": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ },
+ "op": {
+ "$ref": "#/components/schemas/ChangeOpOutput"
+ },
+ "type": {
+ "$ref": "#/components/schemas/ChangeTypeOutput"
+ }
+ }
+ },
+ "EntityKindOutput": {
+ "type": "string",
+ "description": "Logical graph entity namespace.",
+ "enum": [
+ "node",
+ "edge"
+ ]
+ },
"ErrorCode": {
"type": "string",
"enum": [
@@ -2268,6 +4279,39 @@
"error"
],
"properties": {
+ "blob_range": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/BlobRangeOutput",
+ "description": "Set with HTTP 416 for a valid but unsatisfiable managed Blob byte range.\n`start..end` is half-open and `length` is the selected Blob length."
+ }
+ ]
+ },
+ "change_diff_refusal": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeDiffRefusalOutput",
+ "description": "Set with HTTP 409 when a commit entity diff is refused (parentless\ncommit or an unprovable schema boundary)."
+ }
+ ]
+ },
+ "change_feed_gap": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/ChangeFeedGapOutput",
+ "description": "Set with HTTP 410 when retained history can no longer reconstruct a\nchange continuation. Recover via the baseline handshake."
+ }
+ ]
+ },
"code": {
"oneOf": [
{
@@ -2281,25 +4325,36 @@
"error": {
"type": "string"
},
- "key_conflict": {
+ "external_blob_source": {
"oneOf": [
{
"type": "null"
},
{
- "$ref": "#/components/schemas/KeyConflictOutput",
- "description": "Set when a strict keyed insert found an existing or concurrently\ninserted logical id. The caller may choose a different id; replaying\nthe same strict operation will not convert it into an upsert."
+ "$ref": "#/components/schemas/ExternalBlobSourceOutput",
+ "description": "Set with HTTP 424 when an external Blob URI passed admission policy but\nits source could not be probed or read. This optional detail is the\nrolling-safe machine-readable discriminator; `code` is omitted because\n[`ErrorCode`] is a closed compatibility contract."
+ }
+ ]
+ },
+ "full_text_index_rebuild_required": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/FullTextIndexRebuildRequiredOutput",
+ "description": "Set with HTTP 409 when a selected full-text index requires an explicit\nrebuild before search can succeed. Unlike a write-authority conflict,\nthis condition is not cleared by retrying. This additive discriminator\npreserves the closed [`ErrorCode`] contract."
}
]
},
- "manifest_conflict": {
+ "key_conflict": {
"oneOf": [
{
"type": "null"
},
{
- "$ref": "#/components/schemas/ManifestConflictOutput",
- "description": "Set when the conflict is a publisher CAS rejection\n(`ManifestConflictDetails::ExpectedVersionMismatch`). The caller's\npre-write view of `table_key` was at version `expected` but the\nmanifest is now at `actual`. Refresh and retry."
+ "$ref": "#/components/schemas/KeyConflictOutput",
+ "description": "Set when a strict keyed insert found an existing or concurrently\ninserted logical id. The caller may choose a different id; replaying\nthe same strict operation will not convert it into an upsert."
}
]
},
@@ -2309,6 +4364,28 @@
"$ref": "#/components/schemas/MergeConflictOutput"
}
},
+ "precondition_failure": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/PreconditionFailureOutput",
+ "description": "Set when a mutation's graph-commit precondition failed\n(HTTP 412). Like `recovery_required`, the meaning rides this additive\nfield — `ErrorCode` is a closed rolling wire contract."
+ }
+ ]
+ },
+ "published_dataset_version_conflict": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/PublishedDatasetVersionConflictOutput",
+ "description": "Set when the conflict is a publisher CAS rejection. The caller's\npre-write view named the expected published dataset version, but the\ngraph manifest now publishes the actual version. Refresh and retry."
+ }
+ ]
+ },
"read_set_conflict": {
"oneOf": [
{
@@ -2327,7 +4404,7 @@
},
{
"$ref": "#/components/schemas/RecoveryRequiredOutput",
- "description": "Set when an overlapping durable recovery intent must be resolved before\nretry. Its table effects may or may not have started."
+ "description": "Set when an overlapping durable recovery intent must be resolved before\nretry. Its dataset effects may or may not have started."
}
]
},
@@ -2338,7 +4415,7 @@
},
{
"$ref": "#/components/schemas/ResourceLimitOutput",
- "description": "Set when the request must be split into smaller graph commits. The\nrejected attempt has no durable sidecar and no table effect."
+ "description": "Set when the request must be split into smaller graph commits. The\nrejected attempt has no durable sidecar and no dataset effect."
}
]
}
@@ -2354,13 +4431,6 @@
],
"description": "Branch to export. Defaults to `main`."
},
- "table_keys": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "Restrict the export to these table keys. Empty exports all tables."
- },
"type_names": {
"type": "array",
"items": {
@@ -2368,22 +4438,58 @@
},
"description": "Restrict the export to these node/edge type names. Empty exports all types."
}
+ },
+ "additionalProperties": false
+ },
+ "ExternalBlobSourceOutput": {
+ "type": "object",
+ "description": "Structured details for an allowed external Blob source that could not be\nprobed or read. The top-level `code` remains optional so this additive\ndetail can roll out without extending the closed [`ErrorCode`] enum.",
+ "required": [
+ "uri",
+ "reason"
+ ],
+ "properties": {
+ "reason": {
+ "type": "string",
+ "description": "Source-side failure diagnosis. Clients should branch on the presence of\n`external_blob_source`, not parse this human-readable text."
+ },
+ "uri": {
+ "type": "string",
+ "description": "Normalized, credential-free URI spelling (or a redacted placeholder)."
+ }
}
},
- "GraphBatchDeclarationOutput": {
+ "FullTextIndexRebuildRequiredOutput": {
"type": "object",
- "description": "One logical declaration touched by a graph-batch load.\n\nThis deliberately carries the accepted-schema name, not the backing\nmanifest table key, dataset path, or Lance identity.",
+ "description": "A selected full-text index cannot safely serve the current analyzer (HTTP 409).\nThis is not a retryable write conflict: an operator must rebuild the live\nbranch's indexes. Historical snapshots stay unchanged; branch old content\nand rebuild that branch to search it.",
"required": [
- "name",
- "rows_loaded"
+ "index",
+ "reason"
],
"properties": {
- "name": {
+ "index": {
"type": "string"
},
- "rows_loaded": {
+ "reason": {
+ "type": "string",
+ "description": "Human-readable diagnosis; branch on the enclosing detail's presence,\nnot this text, to distinguish the operator-action-required condition."
+ }
+ }
+ },
+ "GraphBatchDeclarationOutput": {
+ "type": "object",
+ "description": "One logical declaration touched by a graph-batch load.\n\nThis deliberately carries the accepted-schema name, not a backing dataset\nselector, path, or Lance identity.",
+ "required": [
+ "name",
+ "entities_loaded"
+ ],
+ "properties": {
+ "entities_loaded": {
"type": "integer",
"minimum": 0
+ },
+ "name": {
+ "type": "string"
}
}
},
@@ -2396,7 +4502,7 @@
"mode",
"nodes",
"edges",
- "total_rows"
+ "total_entities"
],
"properties": {
"actor_id": {
@@ -2418,6 +4524,16 @@
"branch_created": {
"type": "boolean"
},
+ "commit": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/CommitOutput"
+ }
+ ]
+ },
"edges": {
"type": "array",
"items": {
@@ -2435,7 +4551,7 @@
},
"description": "Logical node declarations touched by this batch, sorted by name."
},
- "total_rows": {
+ "total_entities": {
"type": "integer",
"minimum": 0
}
@@ -2507,7 +4623,9 @@
"branch",
"branch_created",
"mode",
- "tables"
+ "nodes",
+ "edges",
+ "total_entities"
],
"properties": {
"actor_id": {
@@ -2529,14 +4647,36 @@
"branch_created": {
"type": "boolean"
},
+ "commit": {
+ "oneOf": [
+ {
+ "type": "null"
+ },
+ {
+ "$ref": "#/components/schemas/CommitOutput"
+ }
+ ]
+ },
+ "edges": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/GraphBatchDeclarationOutput"
+ },
+ "description": "Logical edge declarations touched by this load, sorted by name."
+ },
"mode": {
"$ref": "#/components/schemas/LoadMode"
},
- "tables": {
+ "nodes": {
"type": "array",
"items": {
- "$ref": "#/components/schemas/IngestTableOutput"
- }
+ "$ref": "#/components/schemas/GraphBatchDeclarationOutput"
+ },
+ "description": "Logical node declarations touched by this load, sorted by name."
+ },
+ "total_entities": {
+ "type": "integer",
+ "minimum": 0
},
"uri": {
"type": "string"
@@ -2575,28 +4715,12 @@
},
{
"$ref": "#/components/schemas/LoadMode",
- "description": "How existing rows are handled. Defaults to `merge`."
+ "description": "How existing entities are handled. Defaults to `merge`."
}
]
}
}
},
- "IngestTableOutput": {
- "type": "object",
- "required": [
- "table_key",
- "rows_loaded"
- ],
- "properties": {
- "rows_loaded": {
- "type": "integer",
- "minimum": 0
- },
- "table_key": {
- "type": "string"
- }
- }
- },
"InvokeStoredQueryRequest": {
"type": "object",
"description": "Body for `POST /queries/{name}` — invokes the server-side stored query\nnamed in the path. The query source and name come from the registry,\nnever the body; only the runtime inputs are supplied here.",
@@ -2640,55 +4764,64 @@
},
"KeyConflictOutput": {
"type": "object",
- "description": "A strict insert rejected because `key` already names a row in the keyed\ngraph table. The operation is effect-free when this output is returned;\npartial or ambiguous attempts surface `recovery_required` instead.",
+ "description": "A strict insert rejected because `entity_id` already names an entity in the\nselected node or edge type. The operation is effect-free when this output is returned;\npartial or ambiguous attempts surface `recovery_required` instead.",
"required": [
- "table_key"
+ "entity_kind",
+ "type_name"
],
"properties": {
- "key": {
+ "entity_id": {
"type": [
"string",
"null"
]
},
- "table_key": {
+ "entity_kind": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ },
+ "type_name": {
"type": "string"
}
}
},
- "LoadMode": {
- "type": "string",
- "description": "Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.",
- "enum": [
- "overwrite",
- "append",
- "merge"
- ]
- },
- "ManifestConflictOutput": {
+ "LegacyReadOutput": {
"type": "object",
- "description": "Structured details for a publisher-level OCC failure. Surfaces alongside\nHTTP 409 when a write was rejected because the caller's pre-write view of\none table's manifest version was stale relative to the current head. The\nexpected/actual fields tell the client which table to refresh.",
+ "description": "Indefinitely byte-stable response shape for the deprecated `POST /read`\nroute. The canonical [`ReadOutput`] may grow additive fields; this legacy\nenvelope deliberately cannot carry them.",
"required": [
- "table_key",
- "expected",
- "actual"
+ "query_name",
+ "target",
+ "row_count",
+ "rows"
],
"properties": {
- "actual": {
- "type": "integer",
- "format": "int64",
- "minimum": 0
+ "columns": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
},
- "expected": {
+ "query_name": {
+ "type": "string"
+ },
+ "row_count": {
"type": "integer",
- "format": "int64",
"minimum": 0
},
- "table_key": {
- "type": "string"
+ "rows": {},
+ "target": {
+ "$ref": "#/components/schemas/ReadTargetOutput"
}
}
},
+ "LoadMode": {
+ "type": "string",
+ "description": "Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.",
+ "enum": [
+ "overwrite",
+ "append",
+ "merge"
+ ]
+ },
"MergeConflictKindOutput": {
"type": "string",
"enum": [
@@ -2704,24 +4837,28 @@
"MergeConflictOutput": {
"type": "object",
"required": [
- "table_key",
+ "entity_kind",
+ "type_name",
"kind",
"message"
],
"properties": {
+ "entity_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "entity_kind": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ },
"kind": {
"$ref": "#/components/schemas/MergeConflictKindOutput"
},
"message": {
"type": "string"
},
- "row_id": {
- "type": [
- "string",
- "null"
- ]
- },
- "table_key": {
+ "type_name": {
"type": "string"
}
}
@@ -2783,6 +4920,52 @@
"list"
]
},
+ "PreconditionFailureOutput": {
+ "type": "object",
+ "description": "Structured details for a caller write-precondition failure: HTTP 412, a\nmutation carried `Omnigraph-If-Graph-Commit: `, and the branch\nhead no longer matches that id. The write had no effect; the caller re-reads\nthe branch and decides again. `actual` is `None` on a branch with no commits.",
+ "required": [
+ "expected"
+ ],
+ "properties": {
+ "actual": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "expected": {
+ "type": "string"
+ }
+ }
+ },
+ "PublishedDatasetVersionConflictOutput": {
+ "type": "object",
+ "description": "Structured details for a publisher-level OCC failure. Surfaces alongside\nHTTP 409 when a write was rejected because the caller's pre-write view of\none backing dataset's published version was stale relative to the current\nhead. The expected/actual fields tell the client which dataset to refresh.",
+ "required": [
+ "entity_kind",
+ "type_name",
+ "expected_published_dataset_version",
+ "actual_published_dataset_version"
+ ],
+ "properties": {
+ "actual_published_dataset_version": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "entity_kind": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ },
+ "expected_published_dataset_version": {
+ "type": "integer",
+ "format": "int64",
+ "minimum": 0
+ },
+ "type_name": {
+ "type": "string"
+ }
+ }
+ },
"QueriesCatalogOutput": {
"type": "object",
"description": "Response for `GET /queries`: every stored query in a graph's\nregistry, each with typed parameters.",
@@ -2893,6 +5076,13 @@
"type": "string"
}
},
+ "graph_commit_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Effective graph head commit id of the exact snapshot this read was\nserved from. On a fresh named branch this is the inherited source head,\nso it is immediately usable as `Omnigraph-If-Graph-Commit` (CLI:\n`--if-commit`) for the branch's first conditional write. The id and rows\ncome from one pinned version, so no separate id fetch is needed."
+ },
"query_name": {
"type": "string"
},
@@ -2945,7 +5135,7 @@
},
"ReadSetConflictOutput": {
"type": "object",
- "description": "Structured authority mismatch for a prepared write. Values are\nstrings because members include optional graph commit ids and future\nauthority tokens, not only numeric table versions.",
+ "description": "Structured authority mismatch for a prepared write. Values are\nstrings because members include optional graph commit ids and future\nauthority tokens, not only numeric published dataset versions.",
"required": [
"member"
],
@@ -2997,7 +5187,7 @@
},
"ResourceLimitOutput": {
"type": "object",
- "description": "A write rejected before durable recovery ownership because its bounded\nphysical plan exceeded an explicit row, byte, or transaction-chain ceiling.",
+ "description": "A write rejected before durable recovery ownership because its bounded\nphysical plan exceeded an explicit entity, byte, or transaction-chain ceiling.",
"required": [
"resource",
"limit",
@@ -3026,14 +5216,14 @@
"supported",
"applied",
"step_count",
- "manifest_version",
+ "graph_manifest_version",
"steps"
],
"properties": {
"applied": {
"type": "boolean"
},
- "manifest_version": {
+ "graph_manifest_version": {
"type": "integer",
"format": "int64",
"minimum": 0
@@ -3062,7 +5252,7 @@
"properties": {
"allow_data_loss": {
"type": "boolean",
- "description": "When true, promote every `DropMode::Soft` step in the plan to\n`DropMode::Hard`, making the prior column data unreachable\nafter the apply. Matches the CLI's `--allow-data-loss` flag.\nDefaults to `false` (drops remain reversible via time travel)."
+ "description": "When true, promote every `DropMode::Soft` step in the plan to\n`DropMode::Hard`, making the prior property data unreachable\nafter the apply. Matches the CLI's `--allow-data-loss` flag.\nDefaults to `false` (drops remain reversible via time travel)."
},
"schema_source": {
"type": "string",
@@ -3082,67 +5272,71 @@
}
}
},
- "SnapshotOutput": {
+ "SnapshotDatasetOutput": {
"type": "object",
"required": [
- "branch",
- "manifest_version",
- "internal_schema_version",
- "tables"
+ "entity_kind",
+ "type_name",
+ "dataset_path",
+ "published_dataset_version",
+ "entity_count"
],
"properties": {
- "branch": {
+ "dataset_path": {
"type": "string"
},
- "internal_schema_version": {
+ "entity_count": {
"type": "integer",
- "format": "int32",
- "description": "The on-disk internal-schema (storage-format) version this graph's branch\nis stamped at.",
+ "format": "int64",
"minimum": 0
},
- "manifest_version": {
+ "entity_kind": {
+ "$ref": "#/components/schemas/EntityKindOutput"
+ },
+ "native_dataset_branch": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "published_dataset_version": {
"type": "integer",
"format": "int64",
"minimum": 0
},
- "tables": {
- "type": "array",
- "items": {
- "$ref": "#/components/schemas/SnapshotTableOutput"
- }
+ "type_name": {
+ "type": "string"
}
}
},
- "SnapshotTableOutput": {
+ "SnapshotOutput": {
"type": "object",
"required": [
- "table_key",
- "table_path",
- "table_version",
- "row_count"
+ "graph_branch",
+ "graph_manifest_version",
+ "internal_schema_version",
+ "datasets"
],
"properties": {
- "row_count": {
- "type": "integer",
- "format": "int64",
- "minimum": 0
- },
- "table_branch": {
- "type": [
- "string",
- "null"
- ]
- },
- "table_key": {
- "type": "string"
+ "datasets": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SnapshotDatasetOutput"
+ }
},
- "table_path": {
+ "graph_branch": {
"type": "string"
},
- "table_version": {
+ "graph_manifest_version": {
"type": "integer",
"format": "int64",
"minimum": 0
+ },
+ "internal_schema_version": {
+ "type": "integer",
+ "format": "int32",
+ "description": "The on-disk internal-schema (storage-format) version this graph's branch\nis stamped at.",
+ "minimum": 0
}
}
}