From 74073c8d7065fe2b529ea019014061907e2e4be0 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 31 Aug 2026 21:24:00 +0300 Subject: [PATCH 1/2] feat: align SDK and MCP with Omnigraph v0.10 --- .github/workflows/ci.yml | 2 +- .github/workflows/e2e.yml | 55 +- .github/workflows/release.yml | 5 +- README.md | 22 +- package.json | 7 +- packages/mcp/README.md | 49 +- packages/mcp/cookbook-descriptions.json | 9 +- packages/mcp/package.json | 4 +- packages/mcp/scripts/sync-cookbook.ts | 6 +- packages/mcp/src/server.ts | 162 +- packages/mcp/src/version.gen.ts | 2 +- packages/mcp/test/server.test.ts | 207 +- packages/sdk/README.md | 165 +- packages/sdk/package.json | 4 +- packages/sdk/src/client.ts | 33 +- packages/sdk/src/errors.ts | 100 +- packages/sdk/src/generated/index.ts | 61 +- packages/sdk/src/generated/types.gen.ts | 1165 ++++++++- packages/sdk/src/index.ts | 29 +- packages/sdk/src/internals.ts | 6 + packages/sdk/src/resources/blobs.ts | 89 + packages/sdk/src/resources/changes.ts | 116 + packages/sdk/src/resources/commits.ts | 30 +- packages/sdk/src/resources/queries.ts | 18 +- packages/sdk/src/transport.ts | 12 +- packages/sdk/src/types.ts | 59 +- packages/sdk/src/version.gen.ts | 2 +- packages/sdk/test/blobs.test.ts | 168 ++ packages/sdk/test/case.test.ts | 8 +- packages/sdk/test/changes.test.ts | 230 ++ packages/sdk/test/client.test.ts | 75 +- packages/sdk/test/commits.test.ts | 84 +- packages/sdk/test/e2e.test.ts | 205 +- packages/sdk/test/enums.test.ts | 4 +- packages/sdk/test/errors.test.ts | 47 +- packages/sdk/test/fixtures/data.jsonl | 2 +- packages/sdk/test/fixtures/queries.gq | 17 + packages/sdk/test/fixtures/schema.pg | 1 + packages/sdk/test/queries.test.ts | 23 + packages/sdk/test/schema.test.ts | 14 +- packages/sdk/test/transport.test.ts | 17 +- scripts/check-coverage.ts | 2 +- scripts/check-drift.ts | 19 +- scripts/check-versions.ts | 5 +- scripts/server-pin.test.ts | 29 + scripts/server-pin.ts | 47 + scripts/sync-spec.ts | 14 +- spec/openapi.json | 3182 +++++++++++++++++++---- 48 files changed, 5722 insertions(+), 890 deletions(-) create mode 100644 packages/sdk/src/resources/blobs.ts create mode 100644 packages/sdk/src/resources/changes.ts create mode 100644 packages/sdk/test/blobs.test.ts create mode 100644 packages/sdk/test/changes.test.ts create mode 100644 packages/sdk/test/fixtures/queries.gq create mode 100644 scripts/server-pin.test.ts create mode 100644 scripts/server-pin.ts 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..2165289 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. +This branch targets the **upcoming 0.10.0**, including the unmerged [server PR #581](https://github.com/ModernRelay/omnigraph/pull/581) candidate at `d043cf148e37c4356deb497835db593a2c32d270`. It is not a published-server compatibility claim. Publishing is blocked while `serverRef` is present, both by 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..1294355 100644 --- a/package.json +++ b/package.json @@ -11,17 +11,20 @@ }, "packageManager": "pnpm@9.15.0", "omnigraph": { - "serverVersion": "0.9.0" + "serverVersion": "0.10.0", + "serverRef": "d043cf148e37c4356deb497835db593a2c32d270" }, "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..c693c4d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -11,7 +11,11 @@ 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. + +This branch prepares **v0.10** against an immutable, unmerged server candidate. +It is not a published release. The repository's source pin blocks publication +until the final server tag exists and its contract is revalidated. ## First call @@ -38,7 +42,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 +51,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 +69,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 +111,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 +124,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 +137,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 +160,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 +245,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 +286,41 @@ 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 } } } From 9b94d5b052696de9acc9525d8d39986586a93217 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 31 Aug 2026 23:31:04 +0300 Subject: [PATCH 2/2] release: pin SDK to v0.10.0 server tag --- README.md | 2 +- package.json | 3 +-- packages/sdk/README.md | 12 +++++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2165289..0368fe7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ TypeScript packages for the [Omnigraph](https://github.com/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. -This branch targets the **upcoming 0.10.0**, including the unmerged [server PR #581](https://github.com/ModernRelay/omnigraph/pull/581) candidate at `d043cf148e37c4356deb497835db593a2c32d270`. It is not a published-server compatibility claim. Publishing is blocked while `serverRef` is present, both by the release workflow and each package's `prepublishOnly` hook. +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. diff --git a/package.json b/package.json index 1294355..8e7cb84 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,7 @@ }, "packageManager": "pnpm@9.15.0", "omnigraph": { - "serverVersion": "0.10.0", - "serverRef": "d043cf148e37c4356deb497835db593a2c32d270" + "serverVersion": "0.10.0" }, "scripts": { "sync-spec": "tsx scripts/sync-spec.ts", diff --git a/packages/sdk/README.md b/packages/sdk/README.md index c693c4d..b166de8 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -13,9 +13,8 @@ npm install @modernrelay/omnigraph 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. -This branch prepares **v0.10** against an immutable, unmerged server candidate. -It is not a published release. The repository's source pin blocks publication -until the final server tag exists and its contract is revalidated. +**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 @@ -293,6 +292,13 @@ while an upcoming release is being prepared. ### Migrating from v0.9 +This is a coordinated upgrade, not a rolling one: stop application traffic +while upgrading the CLI, server, and clients. Existing entities do not require +export/import, but old full-text indexes need an explicit rebuild on each +affected live branch. Follow the server's +[upgrade guide](https://github.com/ModernRelay/omnigraph/blob/v0.10.0/docs/user/operations/upgrade.md#full-text-index-upgrade); +the SDK does not perform this operator maintenance. + No legacy-key aliases are fabricated; the public camelCase names follow v0.10. | v0.9 | v0.10 |