From 620358242a2228f0538f7d662ac4c4781e825255 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Wed, 30 Sep 2026 13:50:27 +0200 Subject: [PATCH 1/3] release: sync SDK + MCP to omnigraph-server v0.11.0 - serverVersion 0.11.0; spec/openapi.json synced from the v0.11.0 tag; generated types and SERVER_VERSION regenerated; both packages at 0.11.0 - og.readiness(): GET /readyz (new in v0.11), a flat unauthenticated path; a draining server's 503 is returned as data, not thrown - exported Readiness, BranchOutcome (Change.outcome, from branch statements on POST /mutate) and SystemColumns (Schema.systemColumns) - MCP cookbook: expose upstream changes.md (commits, change feeds, v0.11 branch statements); skip blobs.md (blob cells are read through /blob, which the MCP does not wrap) --- package.json | 2 +- packages/mcp/cookbook-descriptions.json | 7 +- packages/mcp/package.json | 2 +- packages/mcp/src/version.gen.ts | 2 +- packages/mcp/test/server.test.ts | 1 + packages/sdk/package.json | 2 +- packages/sdk/src/client.ts | 13 ++ packages/sdk/src/generated/index.ts | 8 + packages/sdk/src/generated/types.gen.ts | 155 +++++++++++++- packages/sdk/src/index.ts | 3 + packages/sdk/src/transport.ts | 3 +- packages/sdk/src/types.ts | 11 + packages/sdk/src/version.gen.ts | 2 +- packages/sdk/test/readiness.test.ts | 42 ++++ spec/openapi.json | 268 ++++++++++++++++++++++-- 15 files changed, 483 insertions(+), 38 deletions(-) create mode 100644 packages/sdk/test/readiness.test.ts diff --git a/package.json b/package.json index 8e7cb84..bff6947 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "packageManager": "pnpm@9.15.0", "omnigraph": { - "serverVersion": "0.10.0" + "serverVersion": "0.11.0" }, "scripts": { "sync-spec": "tsx scripts/sync-spec.ts", diff --git a/packages/mcp/cookbook-descriptions.json b/packages/mcp/cookbook-descriptions.json index 28fda49..33356e3 100644 --- a/packages/mcp/cookbook-descriptions.json +++ b/packages/mcp/cookbook-descriptions.json @@ -15,6 +15,10 @@ "search": { "title": "Vector and full-text search", "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." + }, + "changes": { + "title": "Commits, change feeds and branch statements", + "description": "Read before `commits_changes`, `changes_poll`, or a conditional `mutate`. Covers exact read/write positions (`graphCommitId` → `ifGraphCommit`, 412 means no effects), durable feed consumption, 410 baseline resets, and the v0.11 branch statements accepted by `mutate` (their `outcome` receipts, and why a merge's `commit` is not an exact-attempt receipt)." } }, "skipped": { @@ -24,6 +28,7 @@ "server-policy": "Server-deployment concerns (Cedar policy, auth tokens), out of scope for an MCP client.", "cluster": "Operator/CLI concern — declaring and converging a deployment via cluster.yaml + `omnigraph cluster apply`. The MCP wraps the data-plane HTTP API, which cannot apply cluster config.", "migrations": "Pre-0.7.0 → 0.7.0 migration reference for config files, CLI flags, and command spellings — a CLI/operator guide, not actionable through the HTTP API the MCP wraps.", - "stored-queries": "Stored-query declaration is a cluster/operator concern (cluster.yaml); the MCP wraps the data-plane HTTP API and does not currently expose a stored-query invoke tool." + "stored-queries": "Stored-query declaration is a cluster/operator concern (cluster.yaml); the MCP wraps the data-plane HTTP API and does not currently expose a stored-query invoke tool.", + "blobs": "Blob cells are read through the dedicated `GET`/`HEAD /blob` endpoints, which this MCP does not wrap; writing a Blob (`base64:` or URI) uses the ordinary `mutate`/`load` paths the other references cover." } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8b71aae..01c1a3a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@modernrelay/omnigraph-mcp", - "version": "0.10.0", + "version": "0.11.0", "description": "MCP server exposing an Omnigraph database to LLM clients (Tools + Resources, stdio transport).", "license": "MIT", "repository": { diff --git a/packages/mcp/src/version.gen.ts b/packages/mcp/src/version.gen.ts index 33c3061..cd98388 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.10.0"; +export const MCP_PACKAGE_VERSION = "0.11.0"; diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 54405ce..ae1c5d5 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -364,6 +364,7 @@ describe('omnigraph-mcp server', () => { const uris = r.resources.map((res) => res.uri).sort(); expect(uris).toEqual( [ + 'omnigraph://best-practices/changes', 'omnigraph://best-practices/data', 'omnigraph://best-practices/index', 'omnigraph://best-practices/queries', diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3628958..f4337a4 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@modernrelay/omnigraph", - "version": "0.10.0", + "version": "0.11.0", "description": "TypeScript SDK for the Omnigraph HTTP API.", "license": "MIT", "repository": { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 6c049a7..14c7a0b 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -14,6 +14,7 @@ import type { ExportInput, GraphBatchLoad, Health, + Readiness, Ingest, IngestInput, LoadNdjsonInput, @@ -105,6 +106,18 @@ export default class Omnigraph { return this.t.request('GET', '/healthz', { signal: opts.signal }); } + /** + * Readiness probe (`GET /readyz`, server v0.11+). Unauthenticated. A draining + * server answers 503 with the same body, so both are returned rather than + * thrown: check `ready` / `status`. + */ + readiness(opts: CallOptions = {}): Promise { + return this.t.request('GET', '/readyz', { + signal: opts.signal, + acceptedStatuses: [503], + }); + } + /** * Run a GQ read query (`POST /query`). Read-only. Field names are * `query` / `name`; `params` is a free-form map matched to `$var` diff --git a/packages/sdk/src/generated/index.ts b/packages/sdk/src/generated/index.ts index 5194cd2..f3af2ec 100644 --- a/packages/sdk/src/generated/index.ts +++ b/packages/sdk/src/generated/index.ts @@ -10,6 +10,7 @@ export { BranchMergeOutcome, type BranchMergeOutput, type BranchMergeRequest, + type BranchOutcomeOutput, type ChangeBaselineOutput, type ChangeBaselineRecord, type ChangeBaselineRequest, @@ -193,6 +194,12 @@ export { type QueriesCatalogOutput, type QueryCatalogEntry, type QueryRequest, + type ReadinessData, + type ReadinessError, + type ReadinessErrors, + type ReadinessOutput, + type ReadinessResponse, + type ReadinessResponses, type ReadOutput, type ReadRequest, type ReadSetConflictOutput, @@ -204,4 +211,5 @@ export { type SchemaOutput, type SnapshotDatasetOutput, type SnapshotOutput, + type SystemColumnsOutput, } from "./types.gen"; diff --git a/packages/sdk/src/generated/types.gen.ts b/packages/sdk/src/generated/types.gen.ts index ed45cb2..2978007 100644 --- a/packages/sdk/src/generated/types.gen.ts +++ b/packages/sdk/src/generated/types.gen.ts @@ -105,6 +105,28 @@ export type BranchMergeRequest = { target?: string | null; }; +/** + * The effect of a branch statement sent to `POST /mutate`, tagged by `kind`. + * A merge conflict has no kind: it is the 409 `POST /branches/merge` answers. + * The statement grammar is `BranchStmt` in `omnigraph-compiler`. + */ +export type BranchOutcomeOutput = + | { + from: string; + kind: "created"; + name: string; + } + | { + kind: "deleted"; + name: string; + } + | { + kind: "merged"; + merge: BranchMergeOutcome; + source: string; + target: string; + }; + /** * Terminal payload of a baseline stream: the captured snapshot commit and * the cursor that resumes the feed immediately after it. @@ -264,7 +286,8 @@ export type ChangeFeedOutput = { export type ChangeImageOutput = { endpoints?: null | ChangeEndpointsOutput; /** - * Exact logical property values; user-schema keys verbatim. + * Exact logical property values, user-schema keys verbatim; a null cell + * keeps its key, an absent key was outside that commit's schema. */ properties: unknown; }; @@ -288,10 +311,27 @@ export type ChangeOpOutput = export type ChangeOutput = { actor_id?: string | null; + /** + * Edges the mutation touched, under the `affected_nodes` rule. + */ affected_edges: number; + /** + * Nodes the mutation touched. Not reported for a branch statement, + * which moves refs, not nodes or edges: `0` whenever `outcome` is present. + */ affected_nodes: number; + /** + * The branch that received the effect. For a branch statement: the + * created branch, the deleted branch (gone by the time this is read), + * or the merge target. + */ branch: string; commit?: null | CommitOutput; + outcome?: null | BranchOutcomeOutput; + /** + * The declared mutation's name, or a branch statement's two keywords + * (`branch create`, `branch delete`, `branch merge`). + */ query_name: string; }; @@ -312,7 +352,9 @@ export type ChangeRequest = { params?: unknown; /** * GQ mutation source containing `insert`, `update`, or `delete` statements. - * May declare multiple named mutations; pick one with `name`. + * May declare multiple named mutations; pick one with `name`. May instead + * be one branch statement (grammar: `BranchStmt` in `omnigraph-compiler`), + * sent with no `name`, `params`, or `branch`. * * Accepts the legacy field name `query_source` as a deserialization alias. */ @@ -510,11 +552,18 @@ export type GraphInfo = { */ export type GraphListResponse = { graphs: Array; + /** + * Graphs the applied revision names that this process does not serve, + * for any reason, sorted (RFC 0049). Empty when every applied graph is + * served. + */ + quarantined?: Array; }; export type HealthOutput = { /** - * The internal-schema (storage-format) version this binary writes and reads. + * The newest internal-schema (storage-format) version this binary serves; + * it also reads and writes the preceding legacy-vintage version. */ internal_schema_version: number; source_version?: string | null; @@ -616,9 +665,9 @@ export type KeyConflictOutput = { }; /** - * 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. + * Indefinitely byte-stable envelope of the deprecated `POST /read` route; cell + * spelling follows the JSON writer. The canonical [`ReadOutput`] may grow + * additive fields; this legacy envelope deliberately cannot carry them. */ export type LegacyReadOutput = { columns?: Array; @@ -791,7 +840,9 @@ export type QueryRequest = { * GQ read-query source. May declare one or more named queries; pick one * with `name` when more than one is declared. Mutations * (`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its - * deprecated alias `POST /change`) instead. + * deprecated alias `POST /change`) instead. May instead be the branch + * statement `branch list`, sent with no `name`, `params`, `branch`, or + * `snapshot`. */ query: string; /** @@ -857,6 +908,49 @@ export type ReadTargetOutput = { snapshot?: string | null; }; +/** + * The readiness witness of one replica (`GET /readyz`, RFC 0049): whether + * it is serving or draining, the applied revision it booted from, and how + * many graphs it does and does not serve. Unauthenticated, so it carries + * no graph id: those stay behind `GET /graphs`. + */ +export type ReadinessOutput = { + /** + * The `config_digest` of the applied revision this process booted from. + * Fixed for the life of the process: the server never reloads. + */ + booted_serving_digest?: string | null; + /** + * How many graphs the applied revision names that this process does + * not serve, for any reason. `GET /graphs` names them. + */ + quarantined_graph_count: number; + /** + * False once shutdown has begun; the response is then 503. + */ + ready: boolean; + /** + * How many graphs this process serves. + */ + served_graph_count: number; + /** + * The bound on graceful shutdown, after which the process exits 2. + */ + shutdown_grace_seconds: number; + /** + * The ledger CAS (`sha256:`) the process booted from. + */ + state_cas?: string | null; + /** + * The ledger revision the process booted from. + */ + state_revision: number; + /** + * `serving` or `draining`. + */ + status: string; +}; + export type RecoveryRequiredOutput = { operation_id: string; }; @@ -897,6 +991,7 @@ export type SchemaApplyRequest = { export type SchemaOutput = { schema_source: string; + system_columns?: null | SystemColumnsOutput; }; export type SnapshotDatasetOutput = { @@ -919,6 +1014,15 @@ export type SnapshotOutput = { internal_schema_version: number; }; +/** + * A graph's system column spellings (see `SchemaOutput::system_columns`). + */ +export type SystemColumnsOutput = { + dst: string; + id: string; + src: string; +}; + export type ListGraphsData = { body?: never; path?: never; @@ -1891,7 +1995,7 @@ export type ClusterLoadResponse = export type ClusterLoadNdjsonData = { /** - * 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. + * Strict raw graph-level NDJSON. Each nonblank line is exactly one node envelope {"type":"","id":"","data":{...}} or edge envelope {"edge":"","id":"","from":"","to":"","data":{...}}. `data` defaults to {} and holds user properties; the optional top-level `id` follows ordinary ID semantics. Legacy-vintage graphs also accept `data.id` as identity when top-level `id` is absent and refuse both placements together. Duplicate, unknown, reserved physical, and noncanonical supplied entity-ID members are refused. */ body: string; path: { @@ -1987,7 +2091,7 @@ export type ClusterMutateData = { export type ClusterMutateErrors = { /** - * Bad request + * Bad request - also returned when `branch list` arrives here instead of POST /query, when a request target accompanies a branch statement, when a name or parameters accompany a branch statement, and when a commit precondition accompanies a branch statement */ 400: ErrorOutput; /** @@ -1999,7 +2103,11 @@ export type ClusterMutateErrors = { */ 403: ErrorOutput; /** - * Write-authority conflict + * `branch delete` of a branch that does not exist, as DELETE /branches/{branch} answers + */ + 404: ErrorOutput; + /** + * Write-authority conflict; also `branch create` of a branch that already exists, and a conflicting `branch merge`, whose body carries `merge_conflicts` */ 409: ErrorOutput; /** @@ -2306,7 +2414,7 @@ export type ClusterQueryData = { export type ClusterQueryErrors = { /** - * Bad request - also returned when the query body contains mutations; use POST /mutate (or its deprecated alias POST /change) for write queries + * Bad request - also returned when the query body contains mutations (use POST /mutate, or its deprecated alias POST /change, for write queries), when a control write statement (`branch create`, `branch delete`, `branch merge`) arrives here instead of POST /mutate, when a request target accompanies a branch statement, and when a name or parameters accompany a branch statement */ 400: ErrorOutput; /** @@ -2515,3 +2623,28 @@ export type HealthResponses = { }; export type HealthResponse = HealthResponses[keyof HealthResponses]; + +export type ReadinessData = { + body?: never; + path?: never; + query?: never; + url: "/readyz"; +}; + +export type ReadinessErrors = { + /** + * Draining + */ + 503: ReadinessOutput; +}; + +export type ReadinessError = ReadinessErrors[keyof ReadinessErrors]; + +export type ReadinessResponses = { + /** + * Serving + */ + 200: ReadinessOutput; +}; + +export type ReadinessResponse = ReadinessResponses[keyof ReadinessResponses]; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 4ddb0e5..6161415 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -58,6 +58,9 @@ export type { MutationInput, ExportInput, Health, + Readiness, + BranchOutcome, + SystemColumns, GraphBatchDeclaration, GraphBatchLoad, Ingest, diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts index 3857622..2e408e2 100644 --- a/packages/sdk/src/transport.ts +++ b/packages/sdk/src/transport.ts @@ -25,8 +25,9 @@ export interface TransportOptions { // Paths that are flat management endpoints in every server mode. They must // never be rewritten under a `graphId` prefix, even when one is configured. // - `/healthz` is unauthenticated and graph-independent. +// - `/readyz` is the unauthenticated readiness probe (server v0.11+). // - `/graphs` is the registry endpoint that lists graphs themselves. -const FLAT_PATHS: ReadonlySet = new Set(['/healthz', '/graphs']); +const FLAT_PATHS: ReadonlySet = new Set(['/healthz', '/readyz', '/graphs']); export interface RequestOptions { headers?: Record; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 25e9943..a150814 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -23,6 +23,9 @@ import type { GraphInfo as GraphInfoOutput, GraphListResponse, HealthOutput, + ReadinessOutput, + BranchOutcomeOutput, + SystemColumnsOutput, IngestOutput, IngestRequest, InvokeStoredQueryRequest, @@ -66,6 +69,14 @@ export type CommitList = Camelize; export type GraphInfo = Camelize; export type GraphList = Camelize; export type Health = Camelize; +// Readiness (GET /readyz, server v0.11+): `ready` and `status` ("serving" | "draining"), plus +// served/quarantined graph counts. Returned for both 200 and 503. +export type Readiness = Camelize; +// What a branch statement run through POST /mutate did (server v0.11+): `Change.outcome`. +export type BranchOutcome = Camelize; +// The graph's identity column spellings (`__id`/`__src`/`__dst` on format-9 graphs), from +// `Schema.systemColumns` (server v0.11+). +export type SystemColumns = Camelize; export type Ingest = Camelize; // Strict graph-level NDJSON batch load (POST /load/ndjson). export type GraphBatchLoad = Camelize; diff --git a/packages/sdk/src/version.gen.ts b/packages/sdk/src/version.gen.ts index 1178a1c..f6bedb5 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.10.0"; +export const SERVER_VERSION = "0.11.0"; diff --git a/packages/sdk/test/readiness.test.ts b/packages/sdk/test/readiness.test.ts new file mode 100644 index 0000000..fa9231d --- /dev/null +++ b/packages/sdk/test/readiness.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import Omnigraph, { ServiceUnavailableError } from '../src'; +import { stubFetch } from './helpers'; + +const body = (ready: boolean, status: 'serving' | 'draining') => ({ + ready, + status, + booted_serving_digest: 'f06d', + state_revision: 2, + state_cas: 'sha256:5fa4', + served_graph_count: 1, + quarantined_graph_count: 0, + shutdown_grace_seconds: 30, +}); + +describe('readiness (GET /readyz, server v0.11+)', () => { + it('returns a serving server camelized', async () => { + const { fetch, calls } = stubFetch({ status: 200, body: body(true, 'serving') }); + const r = await new Omnigraph({ baseUrl: 'http://x', fetch }).readiness(); + expect(calls[0]?.url).toBe('http://x/readyz'); + expect(r).toMatchObject({ ready: true, status: 'serving', servedGraphCount: 1, quarantinedGraphCount: 0 }); + }); + + it('returns a draining server (503) instead of throwing', async () => { + const { fetch } = stubFetch({ status: 503, body: body(false, 'draining') }); + const r = await new Omnigraph({ baseUrl: 'http://x', fetch }).readiness(); + expect(r).toMatchObject({ ready: false, status: 'draining' }); + }); + + it('is a flat path: never prefixed with the graph id', async () => { + const { fetch, calls } = stubFetch({ status: 200, body: body(true, 'serving') }); + await new Omnigraph({ baseUrl: 'http://x', graphId: 'g', fetch }).readiness(); + expect(calls[0]?.url).toBe('http://x/readyz'); + }); + + it('still throws on other failures', async () => { + const { fetch } = stubFetch({ status: 500, body: { error: 'boom' } }); + await expect(new Omnigraph({ baseUrl: 'http://x', fetch }).readiness()).rejects.not.toBeInstanceOf( + ServiceUnavailableError, + ); + }); +}); diff --git a/spec/openapi.json b/spec/openapi.json index a569f78..df973c3 100644 --- a/spec/openapi.json +++ b/spec/openapi.json @@ -7,7 +7,7 @@ "name": "MIT", "identifier": "MIT" }, - "version": "0.10.0" + "version": "0.11.0" }, "paths": { "/graphs": { @@ -690,7 +690,7 @@ "branches" ], "summary": "List all branches.", - "description": "Returns branch names sorted alphabetically. Read-only.", + "description": "Returns branch names sorted by name in byte order. Read-only. The GQ statement\n`branch list` on `POST /query` runs the same body.", "operationId": "cluster_listBranches", "parameters": [ { @@ -746,7 +746,7 @@ "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.", + "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. The GQ statement `branch create` on `POST /mutate` runs\nthe same body.", "operationId": "cluster_createBranch", "parameters": [ { @@ -854,7 +854,7 @@ "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`.", + "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`.\n\nThe GQ statement `branch merge` on `POST /mutate` runs the same body\n(without the deletion composition) and answers the same 409 on conflict.", "operationId": "cluster_mergeBranches", "parameters": [ { @@ -982,7 +982,7 @@ "branches" ], "summary": "Delete a branch.", - "description": "**Irreversible.** Removes the branch pointer; commits remain reachable\nonly if referenced by another branch. Returns 404 if the branch does not\nexist.", + "description": "**Irreversible.** Removes the branch pointer; commits remain reachable\nonly if referenced by another branch. Returns 404 if the branch does not\nexist. The GQ statement `branch delete` on `POST /mutate` runs the same\nbody.", "operationId": "cluster_deleteBranch", "parameters": [ { @@ -2295,7 +2295,7 @@ } ], "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.", + "description": "Strict raw graph-level NDJSON. Each nonblank line is exactly one node envelope {\"type\":\"\",\"id\":\"\",\"data\":{...}} or edge envelope {\"edge\":\"\",\"id\":\"\",\"from\":\"\",\"to\":\"\",\"data\":{...}}. `data` defaults to {} and holds user properties; the optional top-level `id` follows ordinary ID semantics. Legacy-vintage graphs also accept `data.id` as identity when top-level `id` is absent and refuse both placements together. Duplicate, unknown, reserved physical, and noncanonical supplied entity-ID members are refused.", "content": { "application/x-ndjson": { "schema": { @@ -2430,7 +2430,7 @@ "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.", + "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.\n\nThe GQ statements `branch create`, `branch delete`, and `branch merge`\n(grammar: `BranchStmt` in `omnigraph-compiler`) are also served\nhere, with no `branch`, `name`, or `params`: each runs the handler body\nof its `/branches` route (same Cedar action, admission check, and errors,\nincluding the 409 of a conflicting merge) and answers a `ChangeOutput`\nwhose `outcome` names the effect. `branch list` is rejected with 400;\nsend it to `POST /query`.", "operationId": "cluster_mutate", "parameters": [ { @@ -2465,7 +2465,7 @@ } }, "400": { - "description": "Bad request", + "description": "Bad request - also returned when `branch list` arrives here instead of POST /query, when a request target accompanies a branch statement, when a name or parameters accompany a branch statement, and when a commit precondition accompanies a branch statement", "content": { "application/json": { "schema": { @@ -2494,8 +2494,18 @@ } } }, + "404": { + "description": "`branch delete` of a branch that does not exist, as DELETE /branches/{branch} answers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorOutput" + } + } + } + }, "409": { - "description": "Write-authority conflict", + "description": "Write-authority conflict; also `branch create` of a branch that already exists, and a conflicting `branch merge`, whose body carries `merge_conflicts`", "content": { "application/json": { "schema": { @@ -3108,7 +3118,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. 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.", + "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.\n\nThe GQ statement `branch list` is also served here, with no `branch`,\n`snapshot`, `name`, or `params`: it answers one row per branch (column\n`name`, byte order) under the same `read` check as `GET /branches`.\n`branch create`, `branch delete`, and `branch merge` are rejected with\n400; send them to `POST /mutate`.", "operationId": "cluster_query", "parameters": [ { @@ -3143,7 +3153,7 @@ } }, "400": { - "description": "Bad request - also returned when the query body contains mutations; use POST /mutate (or its deprecated alias POST /change) for write queries", + "description": "Bad request - also returned when the query body contains mutations (use POST /mutate, or its deprecated alias POST /change, for write queries), when a control write statement (`branch create`, `branch delete`, `branch merge`) arrives here instead of POST /mutate, when a request target accompanies a branch statement, and when a name or parameters accompany a branch statement", "content": { "application/json": { "schema": { @@ -3196,7 +3206,7 @@ "queries" ], "summary": "**Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.", - "description": "Execute a GQ read query. Behavior is unchanged from prior releases; the\nroute is kept indefinitely for byte-stable back-compat. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", + "description": "Execute a GQ read query. The route is kept indefinitely with a byte-stable\nenvelope; cell spelling follows the JSON writer. New integrations\nshould target `POST /query`, which has clean field names (`query` /\n`name`) and a 400-on-mutation guard. Responses from this route include\n`Deprecation: true` and `Link: ; rel=\"successor-version\"`\nheaders per RFC 9745 / RFC 8288 so SDKs and proxies can surface the\nsignal.", "operationId": "cluster_read", "parameters": [ { @@ -3525,6 +3535,38 @@ } } } + }, + "/readyz": { + "get": { + "tags": [ + "health" + ], + "summary": "Readiness witness (RFC 0049).", + "description": "Unauthenticated, and therefore minimal: it reports whether this replica\nis serving or draining, the applied `config_digest` it booted from, the\nledger revision and CAS it read, and how many graphs it serves and does\nnot serve. Graph ids are topology and stay behind `GET /graphs`. Answers\n503 once shutdown has begun; `/healthz` stays 200 while the process is\nalive.", + "operationId": "readiness", + "responses": { + "200": { + "description": "Serving", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessOutput" + } + } + } + }, + "503": { + "description": "Draining", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessOutput" + } + } + } + } + } + } } }, "components": { @@ -3712,6 +3754,80 @@ } } }, + "BranchOutcomeOutput": { + "oneOf": [ + { + "type": "object", + "description": "`name` was forked off `from`.", + "required": [ + "from", + "name", + "kind" + ], + "properties": { + "from": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "created" + ] + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "`name` was removed.", + "required": [ + "name", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "deleted" + ] + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "description": "`source` was merged into `target`; `merge` is the three-way result,\none of `already_up_to_date`, `fast_forward`, `merged`.", + "required": [ + "source", + "target", + "merge", + "kind" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "merged" + ] + }, + "merge": { + "$ref": "#/components/schemas/BranchMergeOutcome" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + ], + "description": "The effect of a branch statement sent to `POST /mutate`, tagged by `kind`.\nA merge conflict has no kind: it is the 409 `POST /branches/merge` answers.\nThe statement grammar is `BranchStmt` in `omnigraph-compiler`." + }, "ChangeBaselineOutput": { "type": "object", "description": "Terminal payload of a baseline stream: the captured snapshot commit and\nthe cursor that resumes the feed immediately after it.", @@ -4020,7 +4136,7 @@ ] }, "properties": { - "description": "Exact logical property values; user-schema keys verbatim." + "description": "Exact logical property values, user-schema keys verbatim; a null cell\nkeeps its key, an absent key was outside that commit's schema." } } }, @@ -4050,14 +4166,17 @@ }, "affected_edges": { "type": "integer", + "description": "Edges the mutation touched, under the `affected_nodes` rule.", "minimum": 0 }, "affected_nodes": { "type": "integer", + "description": "Nodes the mutation touched. Not reported for a branch statement,\nwhich moves refs, not nodes or edges: `0` whenever `outcome` is present.", "minimum": 0 }, "branch": { - "type": "string" + "type": "string", + "description": "The branch that received the effect. For a branch statement: the\ncreated branch, the deleted branch (gone by the time this is read),\nor the merge target." }, "commit": { "oneOf": [ @@ -4065,12 +4184,25 @@ "type": "null" }, { - "$ref": "#/components/schemas/CommitOutput" + "$ref": "#/components/schemas/CommitOutput", + "description": "The commit this write published, if any. For a branch statement: the\ntarget's head, read after the merge released its gates, so under a\nconcurrent writer it may name a later commit than the merge published.\n`null` for `created`, `deleted`, and `already_up_to_date`, which publish\nnothing, and `null` when that head read fails." + } + ] + }, + "outcome": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/BranchOutcomeOutput", + "description": "Present only when the request was a branch statement." } ] }, "query_name": { - "type": "string" + "type": "string", + "description": "The declared mutation's name, or a branch statement's two keywords\n(`branch create`, `branch delete`, `branch merge`)." } } }, @@ -4099,7 +4231,7 @@ }, "query": { "type": "string", - "description": "GQ mutation source containing `insert`, `update`, or `delete` statements.\nMay declare multiple named mutations; pick one with `name`.\n\nAccepts the legacy field name `query_source` as a deserialization alias.", + "description": "GQ mutation source containing `insert`, `update`, or `delete` statements.\nMay declare multiple named mutations; pick one with `name`. May instead\nbe one branch statement (grammar: `BranchStmt` in `omnigraph-compiler`),\nsent with no `name`, `params`, or `branch`.\n\nAccepts the legacy field name `query_source` as a deserialization alias.", "example": "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}" } } @@ -4585,6 +4717,13 @@ "items": { "$ref": "#/components/schemas/GraphInfo" } + }, + "quarantined": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Graphs the applied revision names that this process does not serve,\nfor any reason, sorted (RFC 0049). Empty when every applied graph is\nserved." } } }, @@ -4599,7 +4738,7 @@ "internal_schema_version": { "type": "integer", "format": "int32", - "description": "The internal-schema (storage-format) version this binary writes and reads.", + "description": "The newest internal-schema (storage-format) version this binary serves;\nit also reads and writes the preceding legacy-vintage version.", "minimum": 0 }, "source_version": { @@ -4786,7 +4925,7 @@ }, "LegacyReadOutput": { "type": "object", - "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.", + "description": "Indefinitely byte-stable envelope of the deprecated `POST /read` route; cell\nspelling follows the JSON writer. The canonical [`ReadOutput`] may grow\nadditive fields; this legacy envelope deliberately cannot carry them.", "required": [ "query_name", "target", @@ -5049,7 +5188,7 @@ }, "query": { "type": "string", - "description": "GQ read-query source. May declare one or more named queries; pick one\nwith `name` when more than one is declared. Mutations\n(`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its\ndeprecated alias `POST /change`) instead.", + "description": "GQ read-query source. May declare one or more named queries; pick one\nwith `name` when more than one is declared. Mutations\n(`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its\ndeprecated alias `POST /change`) instead. May instead be the branch\nstatement `branch list`, sent with no `name`, `params`, `branch`, or\n`snapshot`.", "example": "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}" }, "snapshot": { @@ -5174,6 +5313,64 @@ } } }, + "ReadinessOutput": { + "type": "object", + "description": "The readiness witness of one replica (`GET /readyz`, RFC 0049): whether\nit is serving or draining, the applied revision it booted from, and how\nmany graphs it does and does not serve. Unauthenticated, so it carries\nno graph id: those stay behind `GET /graphs`.", + "required": [ + "ready", + "status", + "state_revision", + "served_graph_count", + "quarantined_graph_count", + "shutdown_grace_seconds" + ], + "properties": { + "booted_serving_digest": { + "type": [ + "string", + "null" + ], + "description": "The `config_digest` of the applied revision this process booted from.\nFixed for the life of the process: the server never reloads." + }, + "quarantined_graph_count": { + "type": "integer", + "description": "How many graphs the applied revision names that this process does\nnot serve, for any reason. `GET /graphs` names them.", + "minimum": 0 + }, + "ready": { + "type": "boolean", + "description": "False once shutdown has begun; the response is then 503." + }, + "served_graph_count": { + "type": "integer", + "description": "How many graphs this process serves.", + "minimum": 0 + }, + "shutdown_grace_seconds": { + "type": "integer", + "format": "int64", + "description": "The bound on graceful shutdown, after which the process exits 2.", + "minimum": 0 + }, + "state_cas": { + "type": [ + "string", + "null" + ], + "description": "The ledger CAS (`sha256:`) the process booted from." + }, + "state_revision": { + "type": "integer", + "format": "int64", + "description": "The ledger revision the process booted from.", + "minimum": 0 + }, + "status": { + "type": "string", + "description": "`serving` or `draining`." + } + } + }, "RecoveryRequiredOutput": { "type": "object", "required": [ @@ -5269,6 +5466,17 @@ "properties": { "schema_source": { "type": "string" + }, + "system_columns": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/SystemColumnsOutput", + "description": "The graph's physical system column spellings: `__id`/`__src`/`__dst`\non current-vintage graphs, `id`/`src`/`dst` on legacy ones. This is\nstorage discovery for loaders, exports and raw readers of mixed\nvintages; query results address identity and endpoints through the\nmeta-fields `@id`, `@src` and `@dst` on every vintage. Optional for\ncompatibility with servers predating the field." + } + ] } } }, @@ -5339,6 +5547,26 @@ "minimum": 0 } } + }, + "SystemColumnsOutput": { + "type": "object", + "description": "A graph's system column spellings (see `SchemaOutput::system_columns`).", + "required": [ + "id", + "src", + "dst" + ], + "properties": { + "dst": { + "type": "string" + }, + "id": { + "type": "string" + }, + "src": { + "type": "string" + } + } } }, "securitySchemes": { From 04e1a7cc4b9fea44f498d29b094dc3ecc66c0d85 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Wed, 30 Sep 2026 14:01:13 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20v0.11=20review=20=E2=80=94=20branch?= =?UTF-8?q?=20statements=20carry=20no=20default=20target,=20export=20ident?= =?UTF-8?q?ity,=20receipt=20semantics,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 ++-- packages/mcp/README.md | 11 ++++++--- packages/mcp/src/server.ts | 37 ++++++++++++++++++++++++++---- packages/mcp/test/server.test.ts | 38 ++++++++++++++++++++++++++++++- packages/sdk/README.md | 19 ++++++++++++++-- packages/sdk/src/client.ts | 10 +++++++- packages/sdk/src/types.ts | 10 +++++--- packages/sdk/test/changes.test.ts | 9 ++++++-- 8 files changed, 119 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0368fe7..f11b124 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. -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. +The **v0.11** line targets `omnigraph-server` **v0.11.0**. Upgrade the CLI, server, and client integrations together; see the [v0.10 migration notes](packages/sdk/README.md#migrating-from-v010). 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. @@ -37,7 +37,7 @@ 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.10.0`). If upgrading from a source-pinned candidate, remove `omnigraph.serverRef` after the release tag exists. +1. Bump `package.json#omnigraph.serverVersion` to the new tag (e.g., `0.11.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. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 105b67d..58b0ca2 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -77,10 +77,15 @@ Mutating (`destructiveHint: true` where appropriate — hosts should surface con 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 +### v0.11 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 +publication. A data `mutate` returning `commit: null` means a successful no-op. +A branch statement (`branch create|delete|merge …`, server 0.11) is sent without +a branch — the server refuses one with a target — and reports its effect in +`outcome`: create and delete return `commit: null` although they changed state, +and a merge's `commit` is the target head after the merge, which a concurrent +writer may already have moved. 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. @@ -118,7 +123,7 @@ 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 +retry and branch-head verification advice is refreshed for v0.11. The write/error rules above and the server's initialization instructions are authoritative for this MCP version. diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 7c3a935..b40d962 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -33,7 +33,7 @@ After schema, consult the matching best-practices resource for the task at hand: - omnigraph://best-practices/schema — to understand the .pg schema before writing - 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. +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.11 MCP contract. Workflow norms (violating these breaks things or silently corrupts data): @@ -41,7 +41,7 @@ Workflow norms (violating these breaks things or silently corrupts data): 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 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. +5. Successful mutations and loads return an exact \`commit\` receipt. A data mutation with \`commit: null\` is a successful no-op, not a failed write. A branch statement (\`branch create|delete|merge …\`) reports its effect in \`outcome\`: create and delete return \`commit: null\` although they changed state, and a merge's \`commit\` is the target head after the merge, which a concurrent writer may already have moved. 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. @@ -113,6 +113,29 @@ const FeedStart = z.union([ z.string().startsWith('after:').min(7).transform((value) => value as `after:${string}`), ]); +/** + * Whether a query text is a v0.11 branch statement (`branch create|delete|merge|list …`, RFC 0055). + * Mirrors the engine grammar: leading whitespace and `//` / `/* *\/` comments, then the keyword + * `branch` on a word boundary. A statement names its branches itself, and the server refuses one + * sent with a request target — so the configured default branch must not be applied to it. + */ +export function isBranchStatement(text: string): boolean { + let i = 0; + for (;;) { + while (i < text.length && /\s/.test(text[i]!)) i++; + if (text.startsWith('//', i)) { + const nl = text.indexOf('\n', i); + if (nl < 0) return false; + i = nl + 1; + } else if (text.startsWith('/*', i)) { + const end = text.indexOf('*/', i + 2); + if (end < 0) return false; + i = end + 2; + } else break; + } + return text.startsWith('branch', i) && !/[A-Za-z0-9_]/.test(text[i + 6] ?? ''); +} + export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { const og = new Omnigraph({ baseUrl: opts.baseUrl, @@ -188,7 +211,8 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { query, name, params, - branch: snapshot ? branch : (branch ?? defaultBranch), + // A branch statement names its branches itself; never add the default target to it. + branch: snapshot || isBranchStatement(query) ? branch : (branch ?? defaultBranch), snapshot, })); }, @@ -298,7 +322,10 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { '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 and an exact ' + - 'commit receipt (null for a successful no-op). ifGraphCommit requires the branch head from a prior ' + + 'commit receipt (null for a successful no-op). A branch statement (`branch create|delete|merge …`, ' + + 'server 0.11) is sent without a branch and reports its effect in `outcome` instead: create and ' + + 'delete return commit null although they changed state, and a merge\'s commit is the target head ' + + 'read after the merge, not proof the merge alone published it. 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), @@ -313,7 +340,7 @@ export function createOmnigraphMcpServer(opts: CreateServerOptions): McpServer { query, name, params, - branch: branch ?? defaultBranch, + branch: isBranchStatement(query) ? branch : (branch ?? defaultBranch), }, { ifGraphCommit })), ); diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index ae1c5d5..7769aa9 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -2,7 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { describe, expect, it } from 'vitest'; import { SERVER_VERSION } from '@modernrelay/omnigraph'; -import { createOmnigraphMcpServer, type CreateServerOptions } from '../src/server'; +import { createOmnigraphMcpServer, isBranchStatement, type CreateServerOptions } from '../src/server'; import { MCP_PACKAGE_VERSION } from '../src/version.gen'; const COMMIT = { @@ -490,3 +490,39 @@ it('branches_create honours configured defaultBranch when `from` is omitted', as expect(r.isError).toBe(true); }); }); + +describe('branch statements (server 0.11)', () => { + it.each([ + ['branch create "x" from "main"', true], + [' // note\nbranch merge "x" into "main"', true], + ['/* c */ branch delete "x"', true], + ['branch list', true], + ['query branches() { match { $p: Person } return { $p.name } }', false], + ['branches', false], + ['branch_x', false], + ['/* unterminated branch create "x"', false], + ])('isBranchStatement(%j) is %s', (text, expected) => { + expect(isBranchStatement(text)).toBe(expected); + }); + + it.each([ + ['mutate', '/mutate', 'branch create "x" from "main"'], + ['query', '/query', 'branch list'], + ])('%s sends a statement without the default branch, and a data op with it', async (tool, route, statement) => { + const bodies: Array> = []; + const { client } = await setup({ defaultBranch: 'review', fetch: async (input, init) => { + bodies.push(JSON.parse(String(init?.body))); + expect(flatPath(String(input))).toBe(route); + const body = tool === 'mutate' + ? { branch: 'x', affected_nodes: 0, affected_edges: 0, commit: null, outcome: { kind: 'created', name: 'x', from: 'main' } } + : { columns: [], rows: [] }; + return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); + } }); + const result = await client.callTool({ name: tool, arguments: { query: statement } }); + expect(result.isError).toBeFalsy(); + expect(bodies[0]).not.toHaveProperty('branch'); + if (tool === 'mutate') expect(toolJson(result).outcome).toEqual({ kind: 'created', name: 'x', from: 'main' }); + await client.callTool({ name: tool, arguments: { query: 'query q() { match { $p: Person } return { $p.name } }' } }); + expect(bodies[1]?.branch).toBe('review'); + }); +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index b166de8..ea4c1b0 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -13,8 +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. -**v0.10 targets omnigraph-server v0.10.0.** Upgrade the CLI, server, and client -integrations together; see [Migrating from v0.9](#migrating-from-v09). +**v0.11 targets omnigraph-server v0.11.0.** Upgrade the CLI, server, and client +integrations together; see [Migrating from v0.10](#migrating-from-v010). ## First call @@ -290,6 +290,21 @@ Published SDKs track server **major.minor**, with independent patch versions. and runs live e2e against the same release, or the exact immutable source pin while an upcoming release is being prepared. +### Migrating from v0.10 + +Additive on the wire; no public name changed. What to know: + +- **Entity identity** on graphs created by v0.11 (format 9) is a top-level `id` in load, export and + baseline envelopes — `ExportRecord` now carries it. `data.id` is an ordinary user property there; + legacy graphs still accept it as identity when top-level `id` is absent. Before rebuilding an older + export into a new graph, move `data.id` to the top level + ([upgrade procedure](https://github.com/ModernRelay/omnigraph/blob/v0.11.0/docs/user/operations/upgrade.md#rebuild)). + In queries, identity is `@id` (edges: `@src`, `@dst`). +- **Branch statements** (`branch create|delete|merge …`) run through `og.mutate()` without `branch`; + the effect is `Change.outcome` (`BranchOutcome`). See the `mutate` doc comment for receipt semantics. +- **New:** `og.readiness()` (`GET /readyz`, a 503 is an answer, not an error), `GraphList.quarantined`, + `Schema.systemColumns` (`SystemColumns`). + ### Migrating from v0.9 This is a coordinated upgrade, not a rolling one: stop application traffic diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 14c7a0b..0acabd5 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -139,7 +139,15 @@ export default class Omnigraph { * Pass `opts.ifGraphCommit` from a preceding read to reject stale decisions * with HTTP 412. Uses the dedicated capability route; never falls back to * an unconditional write on an older server. No requests are auto-retried. - * `commit` identifies this publication; `null` means a successful no-op. + * For a data mutation, `commit` identifies this publication; `null` means a + * successful no-op. + * + * A branch statement (`branch create|delete|merge …`, server >= 0.11) names + * its branches itself: send it without `branch`, `name`, `params` or + * `ifGraphCommit` (the server refuses them with HTTP 400). Its effect is in + * `outcome`; create and delete return `commit: null` although they changed + * state, and a merge's `commit` is the target's head read after the merge — + * a concurrent writer may already have moved it. */ mutate(input: MutationInput, opts: ConditionalCallOptions = {}): Promise { if (opts.ifGraphCommit !== undefined) { diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index a150814..b698890 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -122,10 +122,14 @@ export type ChangeBaseline = Camelize< export type ChangeBaselineInput = Camelize< import('./generated/types.gen').ChangeBaselineRequest >; -/** Entity NDJSON envelopes; user property names inside data are never converted. */ +/** + * Entity NDJSON envelopes; user property names inside data are never converted. + * `id` is the entity's identity (server >= 0.11), separate from `data` — where an `id` key is an + * ordinary user property. + */ export type ExportRecord = - | { type: string; data: Record } - | { edge: string; from: string; to: string; data: Record }; + | { type: string; id: string; data: Record } + | { edge: string; id: string; from: string; to: string; data: Record }; /** The OpenAPI record describes the terminal envelope only; entities precede it. */ export type ChangeBaselineRecord = | ExportRecord diff --git a/packages/sdk/test/changes.test.ts b/packages/sdk/test/changes.test.ts index 0b5a8d5..a69939e 100644 --- a/packages/sdk/test/changes.test.ts +++ b/packages/sdk/test/changes.test.ts @@ -104,9 +104,11 @@ describe('change baseline stream', () => { const records = [ { type: 'Person', - data: { first_name: 'Ada', nested_value: { raw_key: 1 } }, + id: 'ada', + // A user property named `id` is not the identity (format-9 graphs). + data: { id: 'employee-7', first_name: 'Ada', nested_value: { raw_key: 1 } }, }, - { edge: 'Knows', from: 'a', to: 'b', data: { since_year: 2020 } }, + { edge: 'Knows', id: 'knows-1', from: 'a', to: 'b', data: { since_year: 2020 } }, { baseline: { snapshot_commit_id: 'c', resume_cursor: 'resume' } }, ]; const { fetch, calls } = stubFetch({ @@ -136,6 +138,9 @@ describe('change baseline stream', () => { records[1], { baseline: { snapshotCommitId: 'c', resumeCursor: 'resume' } }, ]); + // Identity is typed on the entity envelopes, apart from user data. + const ids = actual.flatMap((r) => ('baseline' in r ? [] : [[r.id, r.data.id]])); + expect(ids).toEqual([['ada', 'employee-7'], ['knows-1', undefined]]); }); it.each([ From f032af005b967897120f448773fc9847acd967ae Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Wed, 30 Sep 2026 14:02:30 +0200 Subject: [PATCH 3/3] fix: README receipt and compatibility wording for v0.11; statement detection uses the engine's whitespace --- packages/mcp/src/server.ts | 2 +- packages/mcp/test/server.test.ts | 2 ++ packages/sdk/README.md | 9 +++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index b40d962..9780fae 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -122,7 +122,7 @@ const FeedStart = z.union([ export function isBranchStatement(text: string): boolean { let i = 0; for (;;) { - while (i < text.length && /\s/.test(text[i]!)) i++; + while (i < text.length && ' \t\r\n'.includes(text[i]!)) i++; // the engine's WHITESPACE if (text.startsWith('//', i)) { const nl = text.indexOf('\n', i); if (nl < 0) return false; diff --git a/packages/mcp/test/server.test.ts b/packages/mcp/test/server.test.ts index 7769aa9..ad89ede 100644 --- a/packages/mcp/test/server.test.ts +++ b/packages/mcp/test/server.test.ts @@ -501,6 +501,8 @@ describe('branch statements (server 0.11)', () => { ['branches', false], ['branch_x', false], ['/* unterminated branch create "x"', false], + ['\u00a0branch list', false], // not engine whitespace: the server would not parse it as a statement + ['\t\r\nbranch list', true], ])('isBranchStatement(%j) is %s', (text, expected) => { expect(isBranchStatement(text)).toBe(expected); }); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ea4c1b0..7740ee4 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -41,7 +41,7 @@ That's the whole pattern: instantiate once (with a `graphId`), call methods, get > **`graphId` is required (server 0.7.0).** `omnigraph-server` is cluster-only: every graph-scoped operation is served under `/graphs/{graphId}/…`. A graph-scoped call without a `graphId` throws `ConfigurationError` before hitting the network. Only `og.health()` and `og.graphs.list()` work without one — use the latter to discover ids, then [`og.graph(id)`](#multi-graph-clusters). This SDK targets the matching server release (see [Server compatibility](#server-compatibility)); for a 0.6.x (flat-route) server, stay on `@modernrelay/omnigraph@0.6.x`. -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. +Use **`og.query()`** (read), **`og.mutate()`** (write), and **`og.load()`** (bulk load). Deprecated aliases were removed from the SDK in v0.7; v0.11 does not promise compatibility with older server minor versions. ## What you can do @@ -68,10 +68,15 @@ const { affectedNodes, affectedEdges } = await og.mutate({ }); ``` -Multi-statement mutations publish atomically. Successful mutations return an +Multi-statement mutations publish atomically. Successful data 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`. +A branch statement (`og.mutate({ query: 'branch create "x" from "main"' })`, server 0.11) +is sent without `branch` and reports its effect in `outcome`. Its `commit` is not a +receipt: create and delete return `null` although they changed state, and a merge's +`commit` is the target head after the merge, which a concurrent writer may already have moved. + ### Conditional mutations ```ts