mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-27 02:39:38 +02:00
Compare commits
No commits in common. "main" and "v0.6.0" have entirely different histories.
279 changed files with 21294 additions and 76331 deletions
|
|
@ -2,4 +2,3 @@
|
|||
!Dockerfile
|
||||
!docker/entrypoint.sh
|
||||
!target/release/omnigraph-server
|
||||
!target/release/omnigraph
|
||||
|
|
|
|||
18
.github/CODEOWNERS
vendored
Normal file
18
.github/CODEOWNERS
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# AUTOGENERATED from .github/codeowners-roles.yml. Do not edit by hand.
|
||||
#
|
||||
# To change role membership or path assignments:
|
||||
# 1. Edit .github/codeowners-roles.yml
|
||||
# 2. Run `python3 .github/scripts/render-codeowners.py`
|
||||
# 3. Commit both files together
|
||||
#
|
||||
# CI fails if this file drifts from its source, and rejects PRs that
|
||||
# edit this file directly without also editing the yml.
|
||||
|
||||
* @ragnorc
|
||||
|
||||
crates/** @ragnorc
|
||||
docs/** @ragnorc
|
||||
README.md @ragnorc
|
||||
AGENTS.md @ragnorc
|
||||
CLAUDE.md @ragnorc
|
||||
SECURITY.md @ragnorc
|
||||
34
.github/DISCUSSION_TEMPLATE/rfc.yml
vendored
34
.github/DISCUSSION_TEMPLATE/rfc.yml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
labels: ["rfc"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this to **incubate an RFC** — socialize a design and reach rough
|
||||
consensus before writing the formal document. When it's ready, graduate
|
||||
it into a pull request that adds `docs/rfcs/NNNN-title.md`
|
||||
(see [docs/rfcs/README.md](../blob/main/docs/rfcs/README.md)); a
|
||||
maintainer merging that PR is acceptance.
|
||||
|
||||
For a plain feature request or open-ended idea, use the **Ideas**
|
||||
category instead. For bugs, open an [Issue](../../issues/new/choose).
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem / motivation
|
||||
description: What needs solving, and why is it worth the long-run cost?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: sketch
|
||||
attributes:
|
||||
label: Proposed direction (sketch)
|
||||
description: A rough shape of the design. Detail comes later in the RFC document.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: invariants
|
||||
attributes:
|
||||
label: Invariants touched
|
||||
description: Which items in docs/dev/invariants.md does this affect or risk? Any deny-list brush?
|
||||
validations:
|
||||
required: false
|
||||
55
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
55
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,55 +0,0 @@
|
|||
name: Bug report
|
||||
description: Report a reproducible problem or wrong behavior in OmniGraph.
|
||||
title: "bug: <short summary>"
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Issues are for **reporting problems** — concrete, reproducible bugs.
|
||||
For ideas, feature requests, or questions, please use
|
||||
[Discussions](../../discussions) instead.
|
||||
For a security vulnerability, follow [SECURITY.md](../../blob/main/SECURITY.md) — do **not** file it here.
|
||||
|
||||
A maintainer will triage this; once labelled **`accepted`** it's open for a pull request
|
||||
(see [GOVERNANCE.md](../../blob/main/GOVERNANCE.md)).
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What went wrong, and what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Minimal steps, commands, schema/query, or a failing snippet.
|
||||
placeholder: |
|
||||
1. omnigraph init ...
|
||||
2. omnigraph ...
|
||||
3. observed: ... / expected: ...
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version
|
||||
description: Output of `omnigraph --version` (or the engine/crate version) and how you installed it.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: OS, architecture, and storage backend (local FS / S3 / RustFS / MinIO).
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / output
|
||||
description: Relevant error text or logs. Will be rendered as code.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
13
.github/ISSUE_TEMPLATE/config.yml
vendored
13
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,13 +0,0 @@
|
|||
# Issues are for problem reports only. Disable blank issues so everything is
|
||||
# routed: bugs through the form, everything else to Discussions / SECURITY.md.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💡 Idea, feature request, or RFC
|
||||
url: https://github.com/ModernRelay/omnigraph/discussions
|
||||
about: Propose features and designs in Discussions. RFCs graduate from there into a docs/rfcs/ pull request.
|
||||
- name: ❓ Question or help
|
||||
url: https://github.com/ModernRelay/omnigraph/discussions
|
||||
about: Ask in Discussions — questions are not tracked as Issues.
|
||||
- name: 🔒 Security vulnerability
|
||||
url: https://github.com/ModernRelay/omnigraph/blob/main/SECURITY.md
|
||||
about: Report security issues privately per SECURITY.md — never as a public Issue.
|
||||
29
.github/PULL_REQUEST_TEMPLATE.md
vendored
29
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -1,29 +0,0 @@
|
|||
<!--
|
||||
Thanks for contributing! See CONTRIBUTING.md and GOVERNANCE.md.
|
||||
A substantive PR needs a backing accepted issue or accepted RFC.
|
||||
Maintainers: your internal process applies; the link requirement below
|
||||
is for external contributions.
|
||||
-->
|
||||
|
||||
## What & why
|
||||
|
||||
<!-- One or two sentences: what this changes and why. -->
|
||||
|
||||
## Backing issue / RFC
|
||||
|
||||
<!-- Pick one. A substantive change needs (1) or (2). -->
|
||||
|
||||
- [ ] Fixes an **accepted** issue: Closes #
|
||||
- [ ] Implements / is an **accepted** RFC: <link to docs/rfcs/NNNN-*.md>
|
||||
- [ ] **Trivial fast-lane** (typo / docs / dependency bump / comment / one-line CI) — no issue/RFC required
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Change is focused (one logical change)
|
||||
- [ ] Tests added/updated for behavior changes (or N/A)
|
||||
- [ ] Public docs updated if user-facing surface changed (or N/A)
|
||||
- [ ] Reviewed against [docs/dev/invariants.md](../blob/main/docs/dev/invariants.md) — no Hard Invariant weakened, no deny-list item hit (or justified)
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!-- Anything that helps review: tradeoffs, follow-ups, areas of risk. -->
|
||||
14
.github/branch-protection.json
vendored
14
.github/branch-protection.json
vendored
|
|
@ -1,18 +1,22 @@
|
|||
{
|
||||
"_comment": "Branch protection policy for main. Applied via scripts/apply-branch-protection.sh. See docs/dev/branch-protection.md for rationale. CODEOWNERS was removed (2-person team where both maintainers own everything, so code-owner review added friction without value). Review is no longer code-owner-scoped and no approvals are required; the required CI status checks are the gate. Maintainers merge their own PRs once checks pass.",
|
||||
"_comment": "Branch protection policy for main. Applied via scripts/apply-branch-protection.sh. See docs/branch-protection.md for rationale.",
|
||||
"required_status_checks": {
|
||||
"strict": true,
|
||||
"contexts": [
|
||||
"Classify Changes",
|
||||
"Check AGENTS.md Links",
|
||||
"Test omnigraph-server --features aws"
|
||||
"Test Workspace",
|
||||
"Test omnigraph-server --features aws",
|
||||
"CODEOWNERS / drift",
|
||||
"CODEOWNERS / noedit"
|
||||
]
|
||||
},
|
||||
"enforce_admins": false,
|
||||
"required_pull_request_reviews": {
|
||||
"dismiss_stale_reviews": false,
|
||||
"require_code_owner_reviews": false,
|
||||
"required_approving_review_count": 0,
|
||||
"dismissal_restrictions": {},
|
||||
"dismiss_stale_reviews": true,
|
||||
"require_code_owner_reviews": true,
|
||||
"required_approving_review_count": 1,
|
||||
"require_last_push_approval": false
|
||||
},
|
||||
"restrictions": null,
|
||||
|
|
|
|||
54
.github/codeowners-roles.yml
vendored
Normal file
54
.github/codeowners-roles.yml
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Source of truth for .github/CODEOWNERS.
|
||||
#
|
||||
# How to change role membership or path assignments:
|
||||
# 1. Edit this file.
|
||||
# 2. Run `python3 .github/scripts/render-codeowners.py` to regenerate
|
||||
# .github/CODEOWNERS.
|
||||
# 3. Commit both files in the same PR.
|
||||
#
|
||||
# CI fails on drift between this source and the generated CODEOWNERS
|
||||
# (see .github/workflows/codeowners.yml). CI also rejects direct edits
|
||||
# to .github/CODEOWNERS that don't accompany a change here.
|
||||
#
|
||||
# Why a generator instead of editing CODEOWNERS directly?
|
||||
# The yml is the audit trail: `git log .github/codeowners-roles.yml`
|
||||
# shows every role change with a reviewable diff and a merge commit.
|
||||
# The rendered CODEOWNERS is what GitHub reads at PR time.
|
||||
|
||||
roles:
|
||||
engineering:
|
||||
description: >
|
||||
All production code under crates/**. Engine, CLI, server,
|
||||
compiler.
|
||||
members:
|
||||
- ragnorc
|
||||
|
||||
docs:
|
||||
description: >
|
||||
Documentation under docs/**, plus repo-level docs (README.md,
|
||||
AGENTS.md, CLAUDE.md symlink, SECURITY.md).
|
||||
members:
|
||||
- ragnorc
|
||||
|
||||
# Path → role mapping. GitHub CODEOWNERS uses "last match wins"
|
||||
# semantics — when multiple patterns match a file, only the last
|
||||
# matching pattern's owners apply. The generator handles this by
|
||||
# emitting `default` as the first `*` line and the specific patterns
|
||||
# below afterward, so specific paths override the catch-all.
|
||||
#
|
||||
# Within this list, order matters only between overlapping specific
|
||||
# patterns (the later one wins). Today nothing overlaps; future
|
||||
# additions should keep more-specific patterns later.
|
||||
paths:
|
||||
"crates/**": [engineering]
|
||||
"docs/**": [docs]
|
||||
"README.md": [docs]
|
||||
"AGENTS.md": [docs]
|
||||
"CLAUDE.md": [docs]
|
||||
"SECURITY.md": [docs]
|
||||
|
||||
# Catch-all for paths not explicitly mapped (.github/, scripts/,
|
||||
# Cargo.toml, Cargo.lock, openapi.json, LICENSE, etc.). Defaults to
|
||||
# engineering — every change to repo infrastructure needs the
|
||||
# engineering owner's review.
|
||||
default: [engineering]
|
||||
134
.github/scripts/render-codeowners.py
vendored
Executable file
134
.github/scripts/render-codeowners.py
vendored
Executable file
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Render .github/CODEOWNERS from .github/codeowners-roles.yml.
|
||||
|
||||
The yml is the source of truth — editing CODEOWNERS directly is
|
||||
rejected by CI (see .github/workflows/codeowners.yml). This script
|
||||
expands the role-based yml into the flat path→owners format GitHub
|
||||
expects.
|
||||
|
||||
Usage:
|
||||
python3 .github/scripts/render-codeowners.py
|
||||
|
||||
Exits non-zero on:
|
||||
- Missing PyYAML.
|
||||
- Unknown role referenced in `paths` or `default`.
|
||||
- Role with no members (a role must always resolve to at least
|
||||
one owner; otherwise CODEOWNERS would assign nobody and GitHub
|
||||
would silently fall back to "no required reviewer", which
|
||||
defeats the purpose).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"error: PyYAML is required. Install with `pip install pyyaml` "
|
||||
"or `python3 -m pip install pyyaml`."
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = REPO_ROOT / ".github" / "codeowners-roles.yml"
|
||||
OUTPUT = REPO_ROOT / ".github" / "CODEOWNERS"
|
||||
|
||||
BANNER = """\
|
||||
# AUTOGENERATED from .github/codeowners-roles.yml. Do not edit by hand.
|
||||
#
|
||||
# To change role membership or path assignments:
|
||||
# 1. Edit .github/codeowners-roles.yml
|
||||
# 2. Run `python3 .github/scripts/render-codeowners.py`
|
||||
# 3. Commit both files together
|
||||
#
|
||||
# CI fails if this file drifts from its source, and rejects PRs that
|
||||
# edit this file directly without also editing the yml.
|
||||
"""
|
||||
|
||||
|
||||
def resolve(role_name: str, roles: dict) -> list[str]:
|
||||
role = roles.get(role_name)
|
||||
if role is None:
|
||||
sys.exit(
|
||||
f"error: unknown role '{role_name}'. "
|
||||
f"Known roles: {sorted(roles.keys())}"
|
||||
)
|
||||
members = role.get("members") or []
|
||||
if not members:
|
||||
sys.exit(
|
||||
f"error: role '{role_name}' has no members. "
|
||||
f"A role must resolve to at least one owner."
|
||||
)
|
||||
return members
|
||||
|
||||
|
||||
def owners_for(role_names: list[str], roles: dict) -> list[str]:
|
||||
"""Return @-prefixed GitHub handles, deduped, preserving order."""
|
||||
seen: list[str] = []
|
||||
for role_name in role_names:
|
||||
for member in resolve(role_name, roles):
|
||||
handle = f"@{member}"
|
||||
if handle not in seen:
|
||||
seen.append(handle)
|
||||
return seen
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SOURCE.exists():
|
||||
sys.exit(f"error: source file not found: {SOURCE}")
|
||||
spec = yaml.safe_load(SOURCE.read_text())
|
||||
|
||||
roles = spec.get("roles") or {}
|
||||
if not roles:
|
||||
sys.exit("error: codeowners-roles.yml declares no roles")
|
||||
|
||||
paths = spec.get("paths") or {}
|
||||
if not paths:
|
||||
sys.exit("error: codeowners-roles.yml declares no paths")
|
||||
|
||||
lines: list[str] = [BANNER]
|
||||
|
||||
# Pad the path column for alignment. Width is the longest pattern
|
||||
# plus a small margin.
|
||||
width = max(len(p) for p in paths) + 2
|
||||
|
||||
# GitHub CODEOWNERS uses "last match wins" semantics. Emit the
|
||||
# default catch-all `*` FIRST so specific patterns below override
|
||||
# it for the paths they cover. If we emitted `*` last, every file
|
||||
# would resolve to the default owners regardless of more-specific
|
||||
# rules — which would silently nullify any role distinction.
|
||||
if "default" in spec:
|
||||
default_owners = owners_for(spec["default"], roles)
|
||||
lines.append(f"{'*':<{width}} {' '.join(default_owners)}")
|
||||
lines.append("")
|
||||
|
||||
for pattern, role_names in paths.items():
|
||||
owners = owners_for(role_names, roles)
|
||||
lines.append(f"{pattern:<{width}} {' '.join(owners)}")
|
||||
|
||||
lines.append("") # trailing newline so the file ends cleanly
|
||||
|
||||
rendered = "\n".join(lines)
|
||||
|
||||
# Regression check: the catch-all `*` line (if any) must precede
|
||||
# every specific-path line. Failure here means the generator is
|
||||
# silently nullifying specific rules.
|
||||
if "default" in spec:
|
||||
non_comment = [ln for ln in rendered.splitlines() if ln and not ln.startswith("#")]
|
||||
first_pattern = non_comment[0].split()[0] if non_comment else None
|
||||
if first_pattern != "*":
|
||||
sys.exit(
|
||||
f"error: generator invariant violated — first emitted pattern is "
|
||||
f"{first_pattern!r}, expected '*'. CODEOWNERS uses last-match-wins; "
|
||||
f"the catch-all must come first."
|
||||
)
|
||||
|
||||
OUTPUT.write_text(rendered)
|
||||
print(f"wrote {OUTPUT.relative_to(REPO_ROOT)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
101
.github/workflows/ci.yml
vendored
101
.github/workflows/ci.yml
vendored
|
|
@ -88,11 +88,8 @@ jobs:
|
|||
.github/workflows/ci.yml|Cargo.toml|Cargo.lock|crates/*/Cargo.toml) run_rustfs_ci=true ;;
|
||||
crates/omnigraph/src/storage.rs) run_rustfs_ci=true ;;
|
||||
crates/omnigraph/src/db/manifest.rs|crates/omnigraph/src/db/manifest/*) run_rustfs_ci=true ;;
|
||||
crates/omnigraph/tests/s3_storage.rs|crates/omnigraph/tests/write_cost_s3.rs|crates/omnigraph/tests/helpers/*) run_rustfs_ci=true ;;
|
||||
crates/omnigraph/src/table_store.rs|crates/omnigraph/src/instrumentation.rs) run_rustfs_ci=true ;;
|
||||
crates/omnigraph-cluster/src/store.rs|crates/omnigraph-cluster/src/serve.rs) run_rustfs_ci=true ;;
|
||||
crates/omnigraph-cluster/tests/s3_cluster.rs) run_rustfs_ci=true ;;
|
||||
crates/omnigraph-server/tests/s3.rs|crates/omnigraph-server/tests/support/*) run_rustfs_ci=true ;;
|
||||
crates/omnigraph/tests/s3_storage.rs|crates/omnigraph/tests/helpers/*) run_rustfs_ci=true ;;
|
||||
crates/omnigraph-server/tests/server.rs) run_rustfs_ci=true ;;
|
||||
crates/omnigraph-cli/tests/system_local.rs) run_rustfs_ci=true ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -114,46 +111,11 @@ jobs:
|
|||
- name: Verify AGENTS.md ↔ docs/ cross-links
|
||||
run: bash scripts/check-agents-md.sh
|
||||
|
||||
entrypoint_test:
|
||||
name: Container Entrypoint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v5.0.1
|
||||
|
||||
- name: Verify omnigraph-server entrypoint arg composition
|
||||
run: sh docker/entrypoint_test.sh
|
||||
|
||||
test:
|
||||
name: Test Workspace
|
||||
needs: classify_changes
|
||||
# PR latency: the full workspace + failpoints build/test is the slowest
|
||||
# gate (~15min warm, up to the 75min ceiling cold) and dominated PR
|
||||
# turnaround. It now runs only on push to `main` (post-merge), on tags,
|
||||
# and on manual `workflow_dispatch` — NOT on pull_request. Trade-off
|
||||
# accepted deliberately: a regression is caught on the `main` run after
|
||||
# merge rather than before it, so `main` can briefly go red. Mitigations:
|
||||
# (1) `Test Workspace` is removed from required PR checks in
|
||||
# `.github/branch-protection.json` (a required check that never
|
||||
# reports would leave every PR permanently pending);
|
||||
# (2) run the full suite locally before merging risky changes
|
||||
# (`cargo test --workspace --locked`), or trigger this workflow via
|
||||
# the Actions "Run workflow" button (workflow_dispatch) on your branch;
|
||||
# (3) openapi.json is no longer auto-regenerated on PRs (that step lived
|
||||
# here) — regenerate it locally for server/API changes
|
||||
# (`OMNIGRAPH_UPDATE_OPENAPI=1 cargo test -p omnigraph-server --test openapi`)
|
||||
# or the strict drift check fails the post-merge `main` run.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
# 75, not 45: a cold rust-cache (every Cargo.lock change) costs a full
|
||||
# workspace + failpoints-feature build on a 2-core runner, which now
|
||||
# exceeds 45 minutes on slow runner days. A timed-out run never SAVES
|
||||
# its cache, so an undersized budget self-perpetuates: every retry
|
||||
# starts cold and dies the same way (observed 2026-06-11, four runs).
|
||||
# Warm-cache runs stay ~15 minutes; this is headroom, not a target.
|
||||
timeout-minutes: 75
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
|
|
@ -199,18 +161,15 @@ jobs:
|
|||
OMNIGRAPH_UPDATE_OPENAPI: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) && '1' || '' }}
|
||||
run: cargo test --workspace --locked
|
||||
|
||||
- name: Run failpoints feature tests
|
||||
- name: Run failpoints feature test
|
||||
if: needs.classify_changes.outputs.run_full_ci == 'true'
|
||||
# Run after the workspace test so the build cache is warm —
|
||||
# enabling --features failpoints is just an incremental rebuild
|
||||
# of the target crate + the small `fail` crate, not the full
|
||||
# of omnigraph-engine + the small `fail` crate, not the full
|
||||
# dep tree (lance, datafusion). A separate job with its own
|
||||
# cache key would be a fresh ~20min build on first run; this
|
||||
# is ~30s on a warm cache. The cluster feature does not enable
|
||||
# omnigraph/failpoints, so each line rebuilds only its crate.
|
||||
run: |
|
||||
cargo test --locked -p omnigraph-engine --features failpoints --test failpoints
|
||||
cargo test --locked -p omnigraph-cluster --features failpoints --test failpoints
|
||||
# is ~30s on a warm cache.
|
||||
run: cargo test --locked -p omnigraph-engine --features failpoints --test failpoints
|
||||
|
||||
- name: Commit regenerated openapi.json to PR branch
|
||||
if: |
|
||||
|
|
@ -292,9 +251,6 @@ jobs:
|
|||
|
||||
rustfs_integration:
|
||||
name: RustFS S3 Integration
|
||||
# `needs: test` means this is push-/dispatch-only too: on pull_request the
|
||||
# `test` job is skipped, so this dependent is skipped with it. S3
|
||||
# integration runs post-merge on `main`, alongside the workspace suite.
|
||||
needs:
|
||||
- classify_changes
|
||||
- test
|
||||
|
|
@ -335,12 +291,14 @@ jobs:
|
|||
. -> target
|
||||
|
||||
- name: Start RustFS
|
||||
# Pinned to 1.0.0-beta.8 (2026-06-10). beta.4+ refuses "default"
|
||||
# credentials (rustfsadmin/rustfsadmin) unless
|
||||
# RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true is set — fine for
|
||||
# an ephemeral CI container. The three S3 suites were validated
|
||||
# against the beta.8 binary locally before this bump. Keep the pin
|
||||
# explicit (never `latest`) so upgrades are deliberate.
|
||||
# Pinned to 1.0.0-beta.3 (2026-05-14) — the last known-good tag.
|
||||
# `rustfs/rustfs:latest` (1.0.0-beta.4, 2026-05-21) added a
|
||||
# credentials-policy check that refuses to start when
|
||||
# AWS_ACCESS_KEY_ID/SECRET_ACCESS_KEY are values it considers
|
||||
# "default" (rustfsadmin/rustfsadmin in our case). Bumping to
|
||||
# beta.4+ requires either rotating those creds to less-default
|
||||
# values or setting RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true
|
||||
# — deliberate work, not an emergency. Pin first; upgrade later.
|
||||
run: |
|
||||
docker rm -f rustfs >/dev/null 2>&1 || true
|
||||
docker run -d \
|
||||
|
|
@ -349,8 +307,7 @@ jobs:
|
|||
-p 9001:9001 \
|
||||
-e RUSTFS_ACCESS_KEY="${AWS_ACCESS_KEY_ID}" \
|
||||
-e RUSTFS_SECRET_KEY="${AWS_SECRET_ACCESS_KEY}" \
|
||||
-e RUSTFS_ALLOW_INSECURE_DEFAULT_CREDENTIALS=true \
|
||||
rustfs/rustfs:1.0.0-beta.8 \
|
||||
rustfs/rustfs:1.0.0-beta.3 \
|
||||
/data
|
||||
|
||||
- name: Install AWS CLI
|
||||
|
|
@ -373,36 +330,12 @@ jobs:
|
|||
- name: Run RustFS storage tests
|
||||
run: cargo test --locked -p omnigraph-engine --test s3_storage -- --nocapture
|
||||
|
||||
- name: Run RustFS write-path cost gate (RFC-013 step 3a opener)
|
||||
run: cargo test --locked -p omnigraph-engine --test write_cost_s3 -- --nocapture
|
||||
|
||||
- name: Run RustFS server smoke
|
||||
# No name filter: every test in the s3 target is bucket-gated, and a
|
||||
# filter matching nothing passes vacuously (which silently ran zero
|
||||
# tests here for a while — the old filter said s3_repo, the test
|
||||
# said s3_graph).
|
||||
run: cargo test --locked -p omnigraph-server --test s3 -- --nocapture
|
||||
|
||||
- name: Run RustFS cluster e2e
|
||||
run: cargo test --locked -p omnigraph-cluster --test s3_cluster -- --nocapture
|
||||
run: cargo test --locked -p omnigraph-server --test server server_opens_s3_repo_directly_and_serves_snapshot_and_read -- --nocapture
|
||||
|
||||
- name: Run RustFS CLI smoke
|
||||
run: cargo test --locked -p omnigraph-cli --test system_local local_cli_s3_end_to_end_init_load_read_flow -- --nocapture
|
||||
|
||||
- name: Run RustFS recovery-sidecar lifecycle
|
||||
# Sidecar put/list/delete through the S3 storage backend on a
|
||||
# real bucket (the failpoint only wedges the publisher; the
|
||||
# sidecar I/O is exercised for real). Name filter `s3_` matches
|
||||
# the bucket-gated tests in the failpoints target only; the
|
||||
# grep guards against the filter going vacuous (cargo passes
|
||||
# with 0 tests matched) if those tests are ever renamed.
|
||||
run: |
|
||||
output=$(cargo test --locked -p omnigraph-engine --features failpoints --test failpoints s3_ -- --nocapture 2>&1); status=$?
|
||||
echo "$output"
|
||||
[ "$status" -eq 0 ] || exit "$status"
|
||||
echo "$output" | grep -Eq "test result: ok\. [1-9][0-9]* passed" \
|
||||
|| { echo "::error::filter 's3_' matched no tests — vacuous pass"; exit 1; }
|
||||
|
||||
- name: Dump RustFS logs on failure
|
||||
if: failure()
|
||||
run: docker logs rustfs
|
||||
|
|
|
|||
66
.github/workflows/codeowners.yml
vendored
Normal file
66
.github/workflows/codeowners.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: CODEOWNERS
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/codeowners-roles.yml'
|
||||
- '.github/CODEOWNERS'
|
||||
- '.github/scripts/render-codeowners.py'
|
||||
- '.github/workflows/codeowners.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Read-only; we never push from this workflow.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
name: CODEOWNERS matches source
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5.0.1
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5.4.0
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Re-render CODEOWNERS
|
||||
run: python3 .github/scripts/render-codeowners.py
|
||||
|
||||
- name: Reject drift
|
||||
run: |
|
||||
if ! git diff --quiet .github/CODEOWNERS; then
|
||||
echo "::error::.github/CODEOWNERS is out of sync with .github/codeowners-roles.yml."
|
||||
echo "::error::Run \`python3 .github/scripts/render-codeowners.py\` locally and commit the result."
|
||||
echo "--- diff ---"
|
||||
git --no-pager diff .github/CODEOWNERS
|
||||
exit 1
|
||||
fi
|
||||
echo "CODEOWNERS is in sync with its source."
|
||||
|
||||
noedit:
|
||||
name: CODEOWNERS not hand-edited
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5.0.1
|
||||
with:
|
||||
# Need history so we can diff against the PR base.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Reject hand-edits to generated file
|
||||
run: |
|
||||
base="origin/${{ github.base_ref }}"
|
||||
git fetch origin "${{ github.base_ref }}" --quiet
|
||||
changed=$(git diff --name-only "$base" HEAD)
|
||||
edited_generated=$(echo "$changed" | grep -E '^\.github/CODEOWNERS$' || true)
|
||||
edited_source=$(echo "$changed" | grep -E '^\.github/codeowners-roles\.yml$' || true)
|
||||
if [ -n "$edited_generated" ] && [ -z "$edited_source" ]; then
|
||||
echo "::error::This PR edits .github/CODEOWNERS but not its source .github/codeowners-roles.yml."
|
||||
echo "::error::Edit the yml and regenerate via \`python3 .github/scripts/render-codeowners.py\`."
|
||||
exit 1
|
||||
fi
|
||||
echo "CODEOWNERS edits accompany source edits (or no CODEOWNERS edits in this PR)."
|
||||
10
.github/workflows/publish-crates.yml
vendored
10
.github/workflows/publish-crates.yml
vendored
|
|
@ -1,6 +1,6 @@
|
|||
name: Publish to crates.io
|
||||
|
||||
# Publishes the publishable workspace crates to crates.io in dependency order.
|
||||
# Publishes the four workspace crates to crates.io in dependency order.
|
||||
#
|
||||
# Triggers:
|
||||
# - push of any v* tag (future releases auto-publish alongside release.yml)
|
||||
|
|
@ -115,14 +115,10 @@ jobs:
|
|||
|
||||
# Order matters: each crate must precede anything that depends on it.
|
||||
# omnigraph-compiler and omnigraph-policy have no internal deps;
|
||||
# omnigraph-engine depends on both; omnigraph-api-types and
|
||||
# omnigraph-cluster depend on engine (+ compiler); server depends on
|
||||
# engine + api-types + cluster + the two leaf crates; cli depends on
|
||||
# everything.
|
||||
# omnigraph-engine depends on both; server depends on engine + the
|
||||
# two leaf crates; cli depends on everything.
|
||||
publish_if_new omnigraph-compiler
|
||||
publish_if_new omnigraph-policy
|
||||
publish_if_new omnigraph-engine
|
||||
publish_if_new omnigraph-api-types
|
||||
publish_if_new omnigraph-cluster
|
||||
publish_if_new omnigraph-server
|
||||
publish_if_new omnigraph-cli
|
||||
|
|
|
|||
46
.github/workflows/release-edge.yml
vendored
46
.github/workflows/release-edge.yml
vendored
|
|
@ -43,8 +43,6 @@ jobs:
|
|||
asset_name: omnigraph-linux-x86_64
|
||||
- runner: macos-14
|
||||
asset_name: omnigraph-macos-arm64
|
||||
- runner: windows-latest
|
||||
asset_name: omnigraph-windows-x86_64
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
|
|
@ -61,10 +59,6 @@ jobs:
|
|||
if: runner.os == 'macOS'
|
||||
run: brew install protobuf
|
||||
|
||||
- name: Install Windows dependencies
|
||||
if: runner.os == 'Windows'
|
||||
run: choco install protoc -y
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
|
@ -79,8 +73,7 @@ jobs:
|
|||
- name: Build release binaries
|
||||
run: cargo build --release --locked -p omnigraph-cli -p omnigraph-server
|
||||
|
||||
- name: Package Unix release archive
|
||||
if: runner.os != 'Windows'
|
||||
- name: Package release archive
|
||||
run: |
|
||||
mkdir -p release
|
||||
install -m 0755 target/release/omnigraph release/omnigraph
|
||||
|
|
@ -88,22 +81,6 @@ jobs:
|
|||
tar -C release -czf "${{ matrix.asset_name }}.tar.gz" omnigraph omnigraph-server
|
||||
shasum -a 256 "${{ matrix.asset_name }}.tar.gz" > "${{ matrix.asset_name }}.sha256"
|
||||
|
||||
- name: Package Windows release archive
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path release | Out-Null
|
||||
Copy-Item target/release/omnigraph.exe release/omnigraph.exe
|
||||
Copy-Item target/release/omnigraph-server.exe release/omnigraph-server.exe
|
||||
Compress-Archive -Path release/omnigraph.exe, release/omnigraph-server.exe -DestinationPath "${{ matrix.asset_name }}.zip" -Force
|
||||
$hash = (Get-FileHash "${{ matrix.asset_name }}.zip" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash ${{ matrix.asset_name }}.zip" | Out-File -FilePath "${{ matrix.asset_name }}.sha256" -Encoding ascii
|
||||
New-Item -ItemType Directory -Force -Path verify | Out-Null
|
||||
Expand-Archive -Path "${{ matrix.asset_name }}.zip" -DestinationPath verify -Force
|
||||
$items = Get-ChildItem -Path verify -File
|
||||
if ($items.Count -ne 2 -or !(Test-Path verify/omnigraph.exe) -or !(Test-Path verify/omnigraph-server.exe)) {
|
||||
throw "Windows release archive is missing expected binaries"
|
||||
}
|
||||
|
||||
- name: Publish edge release assets
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
|
|
@ -114,22 +91,5 @@ jobs:
|
|||
body: |
|
||||
Rolling prerelease from `${{ github.sha }}`.
|
||||
files: |
|
||||
${{ matrix.asset_name }}.*
|
||||
|
||||
smoke_windows_installer:
|
||||
name: Smoke Windows installer
|
||||
needs: build_release
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v5.0.1
|
||||
|
||||
- name: Install from edge release
|
||||
run: ./scripts/install.ps1 -ReleaseChannel edge -InstallDir "$env:RUNNER_TEMP/omnigraph-bin"
|
||||
|
||||
- name: Smoke installed binaries
|
||||
run: |
|
||||
& "$env:RUNNER_TEMP/omnigraph-bin/omnigraph.exe" version
|
||||
& "$env:RUNNER_TEMP/omnigraph-bin/omnigraph-server.exe" --help
|
||||
${{ matrix.asset_name }}.tar.gz
|
||||
${{ matrix.asset_name }}.sha256
|
||||
|
|
|
|||
135
.github/workflows/release.yml
vendored
135
.github/workflows/release.yml
vendored
|
|
@ -1,34 +1,17 @@
|
|||
name: Release
|
||||
|
||||
# Build per-platform binaries in a matrix, then publish the GitHub release ONCE
|
||||
# from a single job. The matrix used to call `softprops/action-gh-release`
|
||||
# concurrently — three jobs racing to create/finalize the same release, which
|
||||
# exhausted the action's finalize retries and dropped whole platforms' assets.
|
||||
# The matrix now only uploads workflow artifacts; `publish_release` is the sole
|
||||
# writer of the release (no race).
|
||||
#
|
||||
# Triggers:
|
||||
# - push of a v* tag (normal release)
|
||||
# - workflow_dispatch with an explicit `tag` (re-publish a past tag without
|
||||
# re-cutting it; resolves the same `${{ inputs.tag || github.ref_name }}`)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to (re)publish (e.g. v0.7.0). Required for manual dispatches."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build_release:
|
||||
name: Build ${{ matrix.asset_name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
|
@ -37,15 +20,11 @@ jobs:
|
|||
asset_name: omnigraph-linux-x86_64
|
||||
- runner: macos-14
|
||||
asset_name: omnigraph-macos-arm64
|
||||
- runner: windows-latest
|
||||
asset_name: omnigraph-windows-x86_64
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v5.0.1
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref_name }}
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
|
|
@ -57,10 +36,6 @@ jobs:
|
|||
if: runner.os == 'macOS'
|
||||
run: brew install protobuf
|
||||
|
||||
- name: Install Windows dependencies
|
||||
if: runner.os == 'Windows'
|
||||
run: choco install protoc -y
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
|
@ -75,8 +50,7 @@ jobs:
|
|||
- name: Build release binaries
|
||||
run: cargo build --release --locked -p omnigraph-cli -p omnigraph-server
|
||||
|
||||
- name: Package Unix release archive
|
||||
if: runner.os != 'Windows'
|
||||
- name: Package release archive
|
||||
run: |
|
||||
mkdir -p release
|
||||
install -m 0755 target/release/omnigraph release/omnigraph
|
||||
|
|
@ -84,62 +58,21 @@ jobs:
|
|||
tar -C release -czf "${{ matrix.asset_name }}.tar.gz" omnigraph omnigraph-server
|
||||
shasum -a 256 "${{ matrix.asset_name }}.tar.gz" > "${{ matrix.asset_name }}.sha256"
|
||||
|
||||
- name: Package Windows release archive
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path release | Out-Null
|
||||
Copy-Item target/release/omnigraph.exe release/omnigraph.exe
|
||||
Copy-Item target/release/omnigraph-server.exe release/omnigraph-server.exe
|
||||
Compress-Archive -Path release/omnigraph.exe, release/omnigraph-server.exe -DestinationPath "${{ matrix.asset_name }}.zip" -Force
|
||||
$hash = (Get-FileHash "${{ matrix.asset_name }}.zip" -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash ${{ matrix.asset_name }}.zip" | Out-File -FilePath "${{ matrix.asset_name }}.sha256" -Encoding ascii
|
||||
New-Item -ItemType Directory -Force -Path verify | Out-Null
|
||||
Expand-Archive -Path "${{ matrix.asset_name }}.zip" -DestinationPath verify -Force
|
||||
$items = Get-ChildItem -Path verify -File
|
||||
if ($items.Count -ne 2 -or !(Test-Path verify/omnigraph.exe) -or !(Test-Path verify/omnigraph-server.exe)) {
|
||||
throw "Windows release archive is missing expected binaries"
|
||||
}
|
||||
|
||||
# Upload artifacts only — the single `publish_release` job attaches them to
|
||||
# the release, so no two jobs ever write the release concurrently.
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset_name }}
|
||||
path: |
|
||||
${{ matrix.asset_name }}.*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish_release:
|
||||
name: Publish GitHub release
|
||||
needs: build_release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download all build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: Publish release (single writer — no matrix race)
|
||||
- name: Publish GitHub release assets
|
||||
uses: softprops/action-gh-release@v2.5.0
|
||||
with:
|
||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||
files: dist/**
|
||||
overwrite_files: true
|
||||
files: |
|
||||
${{ matrix.asset_name }}.tar.gz
|
||||
${{ matrix.asset_name }}.sha256
|
||||
|
||||
update_homebrew_tap:
|
||||
name: Update Homebrew tap
|
||||
needs: publish_release
|
||||
needs: build_release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Skip if HOMEBREW_TAP_TOKEN is not configured
|
||||
if: env.HOMEBREW_TAP_TOKEN == ''
|
||||
|
|
@ -150,8 +83,6 @@ jobs:
|
|||
- name: Checkout source
|
||||
if: env.HOMEBREW_TAP_SKIP != '1'
|
||||
uses: actions/checkout@v5.0.1
|
||||
with:
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
|
||||
- name: Checkout Homebrew tap
|
||||
if: env.HOMEBREW_TAP_SKIP != '1'
|
||||
|
|
@ -166,32 +97,7 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
./scripts/update-homebrew-formula.sh "${RELEASE_TAG}" homebrew-tap/Formula/omnigraph.rb
|
||||
|
||||
# Diagnostic only: brew is not on PATH on the ubuntu runner by default, so
|
||||
# set it up explicitly. Both this setup and the audit below are best-effort
|
||||
# canaries, not gates — continue-on-error on each keeps a failed/flaky brew
|
||||
# (the action is pinned to a moving @master ref) from skipping the actual
|
||||
# tap publish below. The formula is correct by construction
|
||||
# (update-homebrew-formula.sh), so brew tooling must never block the push.
|
||||
- name: Set up Homebrew
|
||||
if: env.HOMEBREW_TAP_SKIP != '1'
|
||||
continue-on-error: true
|
||||
uses: Homebrew/actions/setup-homebrew@master
|
||||
|
||||
- name: Audit generated formula
|
||||
if: env.HOMEBREW_TAP_SKIP != '1'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# Audit the checked-out tap by name (brew audit rejects bare paths
|
||||
# and needs tap context). Symlink the checkout into Homebrew's Taps
|
||||
# tree so `modernrelay/tap/omnigraph` resolves to it. Offline audit
|
||||
# (no --online) keeps it deterministic; it still catches the
|
||||
# ComponentsOrder/structure class of problems.
|
||||
tap_dir="$(brew --repository)/Library/Taps/modernrelay/homebrew-tap"
|
||||
mkdir -p "$(dirname "$tap_dir")"
|
||||
ln -sfn "$PWD/homebrew-tap" "$tap_dir"
|
||||
brew audit --strict modernrelay/tap/omnigraph
|
||||
./scripts/update-homebrew-formula.sh "${GITHUB_REF_NAME}" homebrew-tap/Formula/omnigraph.rb
|
||||
|
||||
- name: Commit and push formula update
|
||||
if: env.HOMEBREW_TAP_SKIP != '1'
|
||||
|
|
@ -205,28 +111,5 @@ jobs:
|
|||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/omnigraph.rb
|
||||
git commit -m "Update Omnigraph formula to ${RELEASE_TAG}"
|
||||
git commit -m "Update Omnigraph formula to ${GITHUB_REF_NAME}"
|
||||
git push origin HEAD:main
|
||||
|
||||
smoke_windows_installer:
|
||||
name: Smoke Windows installer
|
||||
needs: publish_release
|
||||
if: ${{ inputs.tag != '' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v5.0.1
|
||||
with:
|
||||
ref: ${{ env.RELEASE_TAG }}
|
||||
|
||||
- name: Install from tagged release
|
||||
run: ./scripts/install.ps1 -Version "$env:RELEASE_TAG" -InstallDir "$env:RUNNER_TEMP/omnigraph-bin"
|
||||
|
||||
- name: Smoke installed binaries
|
||||
run: |
|
||||
& "$env:RUNNER_TEMP/omnigraph-bin/omnigraph.exe" version
|
||||
& "$env:RUNNER_TEMP/omnigraph-bin/omnigraph-server.exe" --help
|
||||
|
|
|
|||
130
AGENTS.md
130
AGENTS.md
|
|
@ -16,9 +16,9 @@ Tools that support `@`-imports (Claude Code) auto-include all three files via th
|
|||
|
||||
`CLAUDE.md` is a symlink to this file — there is exactly one source of truth. Edit `AGENTS.md`.
|
||||
|
||||
**Version surveyed:** 0.7.2
|
||||
**Workspace crates:** `omnigraph-compiler`, `omnigraph` (engine), `omnigraph-policy`, `omnigraph-api-types` (shared HTTP wire DTOs), `omnigraph-cluster`, `omnigraph-cli`, `omnigraph-server`
|
||||
**Storage substrate:** Lance 7.x (columnar, versioned, branchable)
|
||||
**Version surveyed:** 0.6.0
|
||||
**Workspace crates:** `omnigraph-compiler`, `omnigraph` (engine), `omnigraph-policy`, `omnigraph-cli`, `omnigraph-server`
|
||||
**Storage substrate:** Lance 6.x (columnar, versioned, branchable)
|
||||
**License:** MIT
|
||||
**Toolchain:** Rust stable, edition 2024
|
||||
|
||||
|
|
@ -33,8 +33,8 @@ OmniGraph is a typed property-graph engine built as a coordination layer over ma
|
|||
- **Multi-modal querying**: vector ANN (`nearest`), full-text (`search`/`fuzzy`/`match_text`/`bm25`), Reciprocal Rank Fusion (`rrf`), and graph traversal (`Expand`, anti-join `not { … }`) in one runtime.
|
||||
- **Branches and commits across the whole graph**: Git-style — every successful publish appends to a commit DAG; merges are three-way at the row level.
|
||||
- **Atomic per-query writes**: `mutate_as` and `load` accumulate insert/update batches into an in-memory `MutationStaging.pending` per touched table; one `stage_*` + `commit_staged` per table runs at end-of-query, then `ManifestBatchPublisher::publish` commits the manifest atomically with per-table `expected_table_versions` CAS. A mid-query failure leaves Lance HEAD untouched on staged tables — no drift, no run state machine, no staging branches. Deletes still inline-commit; D₂ at parse time prevents inserts/updates and deletes from coexisting in one query.
|
||||
- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate. **Cluster-only boot** (RFC-011): the server always boots from a cluster directory (`--cluster <dir | s3://…>`, RFC-005) and serves N graphs (N ≥ 1) under multi-graph routes (`/graphs/{graph_id}/...` + read-only `GET /graphs` enumeration); there are no single-graph flat routes and no positional-URI boot. Per-graph + server-level Cedar policies. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not exposed — operators run `cluster apply` and restart.
|
||||
- **CLI** with two-surface config (RFC-007/008): the team-owned cluster directory (`cluster.yaml`) plus the per-operator `~/.omnigraph/config.yaml` (servers, clusters, credentials, actor, profiles, aliases, defaults). Graphs are addressed via `--store`/`--server`/`--cluster`/`--profile`/operator defaults (RFC-011). Multi-format output (json/jsonl/csv/kv/table).
|
||||
- **HTTP server**: Axum + utoipa OpenAPI, bearer auth (SHA-256 hashed, optional AWS Secrets Manager). Cedar policy enforcement is engine-wide — every `_as` writer calls `Omnigraph::enforce(action, scope, actor)`, so HTTP, CLI, and embedded SDK consumers all hit the same gate. **Two modes** (v0.6.0+): single-graph (legacy flat routes) and multi-graph (`/graphs/{graph_id}/...` cluster routes + read-only `GET /graphs` enumeration). Per-graph + server-level Cedar policies. Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not exposed — operators edit `omnigraph.yaml` and restart.
|
||||
- **CLI** driven by a single `omnigraph.yaml`; multi-format output (json/jsonl/csv/kv/table).
|
||||
|
||||
Throughout the docs, capabilities are split into **L1 — Inherited from Lance** vs **L2 — Added by OmniGraph**.
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ CLI (omnigraph) HTTP Server (omnigraph-server, Axum)
|
|||
omnigraph (engine) ── ManifestCoordinator, CommitGraph, RunRegistry, GraphIndex (CSR/CSC), exec
|
||||
│
|
||||
▼
|
||||
Lance 7.x ── columnar Arrow, fragments, per-dataset versions/branches, indexes
|
||||
Lance 6.x ── columnar Arrow, fragments, per-dataset versions/branches, indexes
|
||||
│
|
||||
▼
|
||||
Object store (file / s3 / RustFS / MinIO / S3-compat)
|
||||
|
|
@ -73,37 +73,31 @@ Full diagram and concurrency model: [docs/dev/architecture.md](docs/dev/architec
|
|||
| **Lance docs index — fetch upstream Lance docs by problem domain** | **[docs/dev/lance.md](docs/dev/lance.md)** |
|
||||
| **Test coverage map — what's covered, what helpers to reuse, before-every-task checklist** | **[docs/dev/testing.md](docs/dev/testing.md)** |
|
||||
| Architecture, L1/L2 framing, concurrency model | [docs/dev/architecture.md](docs/dev/architecture.md) |
|
||||
| Storage layout, `__manifest` schema, URI schemes, S3 env vars | [docs/user/concepts/storage.md](docs/user/concepts/storage.md) |
|
||||
| `.pg` schema language, types, constraints, annotations, migration planning | [docs/user/schema/index.md](docs/user/schema/index.md) |
|
||||
| Schema-lint codes (`OG-XXX-NNN`), families, severity, suppression | [docs/user/schema/lint.md](docs/user/schema/lint.md) |
|
||||
| `.gq` query language, MATCH/RETURN/ORDER, IR ops, lint codes | [docs/user/queries/index.md](docs/user/queries/index.md) |
|
||||
| Mutations — insert/update/delete, D2, atomicity | [docs/user/mutations/index.md](docs/user/mutations/index.md) |
|
||||
| Search funcs (`nearest`/`bm25`/`rrf`), hybrid ranking | [docs/user/search/index.md](docs/user/search/index.md) |
|
||||
| Indexes (BTREE / inverted / vector / graph topology) | [docs/user/search/indexes.md](docs/user/search/indexes.md) |
|
||||
| Embeddings (engine client, env vars, `@embed`) | [docs/user/search/embeddings.md](docs/user/search/embeddings.md) |
|
||||
| Concepts — what OmniGraph is, L1/L2 framing | [docs/user/concepts/index.md](docs/user/concepts/index.md) |
|
||||
| Quickstart — init → load → query → branch | [docs/user/quickstart.md](docs/user/quickstart.md) |
|
||||
| Branches, commit graph, system branches | [docs/user/branching/index.md](docs/user/branching/index.md) |
|
||||
| Snapshots & time travel | [docs/user/branching/time-travel.md](docs/user/branching/time-travel.md) |
|
||||
| Three-way merge and conflict kinds (user-facing) | [docs/user/branching/merge.md](docs/user/branching/merge.md) |
|
||||
| Transactions and atomicity (per-query atomic; branches as multi-query transactions) | [docs/user/branching/transactions.md](docs/user/branching/transactions.md) |
|
||||
| Direct-publish write path (staging, D2, recovery sidecars; the former Run state machine) | [docs/dev/writes.md](docs/dev/writes.md) |
|
||||
| Storage layout, `__manifest` schema, URI schemes, S3 env vars | [docs/user/storage.md](docs/user/storage.md) |
|
||||
| `.pg` schema language, types, constraints, annotations, migration planning | [docs/user/schema-language.md](docs/user/schema-language.md) |
|
||||
| Schema-lint codes (`OG-XXX-NNN`), families, severity, suppression | [docs/user/schema-lint.md](docs/user/schema-lint.md) |
|
||||
| `.gq` query language, MATCH/RETURN/ORDER, search funcs, mutations, IR ops, lint codes | [docs/user/query-language.md](docs/user/query-language.md) |
|
||||
| Indexes (BTREE / inverted / vector / graph topology) | [docs/user/indexes.md](docs/user/indexes.md) |
|
||||
| Embeddings (compiler + engine clients, env vars, `@embed`) | [docs/user/embeddings.md](docs/user/embeddings.md) |
|
||||
| Branches, commit graph, snapshots, system branches | [docs/user/branches-commits.md](docs/user/branches-commits.md) |
|
||||
| Transactions and atomicity (per-query atomic; branches as multi-query transactions) | [docs/user/transactions.md](docs/user/transactions.md) |
|
||||
| Direct-publish writes (the former Run state machine, now demoted to publisher CAS) | [docs/dev/runs.md](docs/dev/runs.md) |
|
||||
| Three-way merge and conflict kinds | [docs/dev/merge.md](docs/dev/merge.md) |
|
||||
| Diff / change feed (`diff_between`, `diff_commits`) | [docs/user/branching/changes.md](docs/user/branching/changes.md) |
|
||||
| Diff / change feed (`diff_between`, `diff_commits`) | [docs/user/changes.md](docs/user/changes.md) |
|
||||
| Query execution, mutation execution, bulk loader, `load` vs `ingest` | [docs/dev/execution.md](docs/dev/execution.md) |
|
||||
| `optimize` (compaction) and `cleanup` (version GC) | [docs/user/operations/maintenance.md](docs/user/operations/maintenance.md) |
|
||||
| Cluster operator guide (deploy/manage clusters, approvals, recovery, serving) | [docs/user/clusters/index.md](docs/user/clusters/index.md) |
|
||||
| Cedar policy actions, scopes, CLI | [docs/user/operations/policy.md](docs/user/operations/policy.md) |
|
||||
| HTTP server endpoints, auth, error model, body limits | [docs/user/operations/server.md](docs/user/operations/server.md) |
|
||||
| CLI quick-start | [docs/user/cli/index.md](docs/user/cli/index.md) |
|
||||
| CLI command surface and config schema (`~/.omnigraph/config.yaml`) | [docs/user/cli/reference.md](docs/user/cli/reference.md) |
|
||||
| Audit / actor tracking | [docs/user/operations/audit.md](docs/user/operations/audit.md) |
|
||||
| Error taxonomy and result serialization | [docs/user/operations/errors.md](docs/user/operations/errors.md) |
|
||||
| `optimize` (compaction) and `cleanup` (version GC) | [docs/user/maintenance.md](docs/user/maintenance.md) |
|
||||
| Cedar policy actions, scopes, CLI | [docs/user/policy.md](docs/user/policy.md) |
|
||||
| HTTP server endpoints, auth, error model, body limits | [docs/user/server.md](docs/user/server.md) |
|
||||
| CLI quick-start | [docs/user/cli.md](docs/user/cli.md) |
|
||||
| CLI command surface and `omnigraph.yaml` schema | [docs/user/cli-reference.md](docs/user/cli-reference.md) |
|
||||
| Audit / actor tracking | [docs/user/audit.md](docs/user/audit.md) |
|
||||
| Error taxonomy and result serialization | [docs/user/errors.md](docs/user/errors.md) |
|
||||
| Install (binary / Homebrew / source / channels) | [docs/user/install.md](docs/user/install.md) |
|
||||
| Deployment (binary / container / S3-local testing / auth / build variants) | [docs/user/deployment.md](docs/user/deployment.md) |
|
||||
| Deployment (binary / container / RustFS bootstrap / auth / build variants) | [docs/user/deployment.md](docs/user/deployment.md) |
|
||||
| CI / release workflows | [docs/dev/ci.md](docs/dev/ci.md) |
|
||||
| Code ownership (CODEOWNERS source of truth, roles, regeneration) | [docs/dev/codeowners.md](docs/dev/codeowners.md) |
|
||||
| Branch protection policy (declarative, applied via `scripts/apply-branch-protection.sh`) | [docs/dev/branch-protection.md](docs/dev/branch-protection.md) |
|
||||
| Constants & tunables cheat sheet | [docs/user/reference/constants.md](docs/user/reference/constants.md) |
|
||||
| Constants & tunables cheat sheet | [docs/user/constants.md](docs/user/constants.md) |
|
||||
| Per-version release notes | [docs/releases/](docs/releases/) |
|
||||
|
||||
---
|
||||
|
|
@ -124,8 +118,6 @@ This is a decision lens, not a code-size rule. It cuts both ways. Sometimes the
|
|||
|
||||
When evaluating a design, ask: *"what does this look like after 5 more changes like it?"* If the answer is "this converges to one shape", cost is bounded. If it's "this forks every time", the option is mortgaging the future for present convenience — pick differently.
|
||||
|
||||
The same lens has a structural corollary: **one source of truth, cheaply derived.** Lance and the manifest are the source of truth; everything else is a derived view. Maintaining a parallel copy invites drift that compounds over time, and re-deriving a view from the full source on every call makes its cost grow with history. Both are liabilities integrated over time, so both are ruled out the same way: hold a warm derived view and refresh it with a cheap probe, never shadow the source or rebuild from it cold. Invariant 15 in [docs/dev/invariants.md](docs/dev/invariants.md) states this; invariants 1 (respect the substrate) and 7 (indexes are derived state) are instances.
|
||||
|
||||
### Tiebreakers when liability alone is silent
|
||||
|
||||
- **Correctness > simplicity > performance.** Lexicographic — give up performance for simpler code; give up simplicity for correct code; never give up correctness. The deny-list ("no silent failures," "no acks before durable persistence," "no reads of partial commits") is this rule's hard floor.
|
||||
|
|
@ -145,8 +137,6 @@ These are architectural rules that need to be in scope on every change. They're
|
|||
4. **Bearer-token plaintext never persists in process memory.** Tokens are hashed at startup; auth uses constant-time comparison; the actor id is server-resolved from the hash match and must not be settable by the client.
|
||||
5. **Reads always see the current index state for the branch they're reading.** Indexes track the branch head, not historical snapshots. If you change index lifecycle, preserve this guarantee.
|
||||
6. **Stable type IDs survive renames.** Schema migration relies on identity that's stable across rename — don't mint new IDs on rename.
|
||||
7. **Logical contract over physical state.** Physical state (index coverage, fragment layout, compaction versions, staged writes) is derived and rebuildable; it must never fail a logical operation. Check preconditions against logical state and let reconciliation converge the physical state idempotently — genuine logical conflicts still fail loudly. This is the rule rules 1–6 instantiate; full statement and applications in [docs/dev/invariants.md](docs/dev/invariants.md).
|
||||
8. **One source of truth, cheaply derived.** Lance and the manifest are the source of truth; runtime state is a derived view of them. Don't maintain a parallel copy that can drift, and don't re-derive a view from cold storage on every call (that makes cost grow with history). Hold it warm, refresh with a cheap probe.
|
||||
|
||||
### Deny-list (fast-pass review filter — full reasoning in [docs/dev/invariants.md](docs/dev/invariants.md))
|
||||
|
||||
|
|
@ -168,39 +158,12 @@ If a proposal fits one of these, the burden is on the proposer to justify why th
|
|||
- Cloud-only correctness fixes — correctness is always OSS.
|
||||
- Forking the codebase for Cloud — trait-extension only.
|
||||
- Hand-rolling something Lance already does — check the spec first.
|
||||
- Shadowing the source of truth with a maintained parallel copy, or re-deriving a derived view from cold storage per call (cost then scales with history). Hold it warm and refresh cheaply.
|
||||
- Mutating in place state that should be immutable (Lance fragments, index segments) — new segments instead.
|
||||
- Silent failures — OOM, timeout, partial result must all be surfaced and bounded.
|
||||
- Shipping observable behavior as if it weren't part of the contract — output ordering, error-message text, timestamp precision, default-flag values, latency profile. Per Hyrum's Law, every observable behavior gets depended on once shipped; don't expose what you don't want to commit to.
|
||||
|
||||
---
|
||||
|
||||
## Build, test, lint
|
||||
|
||||
Rust stable workspace (edition 2024). `protoc` is a build dependency (`brew install protobuf` / `apt-get install protobuf-compiler libprotobuf-dev`). **Crate dir ≠ package name** for the engine: the directory is `crates/omnigraph` but its Cargo package is `omnigraph-engine` (use that in `-p`). The CLI binary built from `omnigraph-cli` is named `omnigraph`.
|
||||
|
||||
```bash
|
||||
cargo build --workspace --locked # build everything
|
||||
cargo test --workspace --locked # the canonical CI gate (matches CI exactly)
|
||||
cargo run -p omnigraph-cli -- <args> # run the `omnigraph` CLI from source
|
||||
cargo run -p omnigraph-server -- --cluster <dir|s3://...> --bind 0.0.0.0:8080 # run the server from source
|
||||
|
||||
# Run one crate / one test file / one test fn
|
||||
cargo test -p omnigraph-engine --test traversal # one integration-test file (see docs/dev/testing.md)
|
||||
cargo test -p omnigraph-engine --test writes concurrent # one test fn by name substring
|
||||
cargo test -p omnigraph-engine some_inline_test -- --nocapture # show stdout
|
||||
|
||||
# Feature-gated suites (each is its own job in CI, not part of the default run)
|
||||
cargo test -p omnigraph-engine --features failpoints --test failpoints # fault injection
|
||||
cargo build -p omnigraph-server --features aws # AWS Secrets Manager bearer-token source
|
||||
```
|
||||
|
||||
S3-backed tests (`s3_storage`, and the S3 paths in server/CLI system tests) **skip** unless `OMNIGRAPH_S3_TEST_BUCKET` + `AWS_*` (incl. `AWS_ENDPOINT_URL_S3` for non-AWS) are set; CI runs them against containerized RustFS. To run RustFS/MinIO yourself, see [docs/user/deployment.md](docs/user/deployment.md) → *Testing against S3 locally*.
|
||||
|
||||
CI does **not** run `clippy` or `rustfmt` as gates — but `cargo test --workspace --locked` is the exact gate, so run it before pushing. Two non-test CI checks: `scripts/check-agents-md.sh` (doc cross-link integrity — run it after moving/renaming docs) and OpenAPI drift (`crates/omnigraph-server/tests/openapi.rs` regenerates `openapi.json`; set `OMNIGRAPH_UPDATE_OPENAPI=1` to update the checked-in copy when a server/API change is intentional).
|
||||
|
||||
---
|
||||
|
||||
## Quick-reference flows
|
||||
|
||||
```bash
|
||||
|
|
@ -210,12 +173,13 @@ omnigraph init --schema ./schema.pg s3://my-bucket/graph.omni
|
|||
# Bulk load
|
||||
omnigraph load --data ./seed.jsonl --mode overwrite s3://my-bucket/graph.omni
|
||||
|
||||
# Load a review batch onto its own branch (--from forks it if missing)
|
||||
omnigraph load --branch review/2026-04-25 --from main --mode merge --data ./batch.jsonl s3://my-bucket/graph.omni
|
||||
# Branch + ingest a review batch
|
||||
omnigraph branch create --from main review/2026-04-25 s3://my-bucket/graph.omni
|
||||
omnigraph ingest --branch review/2026-04-25 --data ./batch.jsonl s3://my-bucket/graph.omni
|
||||
|
||||
# Run a hybrid (vector + BM25) query — ad-hoc .gq against a store (positional = query name)
|
||||
omnigraph query --query ./queries.gq find_similar \
|
||||
--params '{"q":"trends in AI safety"}' --format table --store s3://my-bucket/graph.omni
|
||||
# Run a hybrid (vector + BM25) query
|
||||
omnigraph read --query ./queries.gq --name find_similar \
|
||||
--params '{"q":"trends in AI safety"}' --format table s3://my-bucket/graph.omni
|
||||
|
||||
# Plan + apply schema migration
|
||||
omnigraph schema plan --schema ./next.pg s3://my-bucket/graph.omni
|
||||
|
|
@ -224,21 +188,17 @@ omnigraph schema apply --schema ./next.pg s3://my-bucket/graph.omni --json
|
|||
# Merge review branch back
|
||||
omnigraph branch merge review/2026-04-25 --into main s3://my-bucket/graph.omni
|
||||
|
||||
# Compact, preview any uncovered drift, then repair/GC after review
|
||||
# Compact + GC (preview, then confirm)
|
||||
omnigraph optimize s3://my-bucket/graph.omni
|
||||
omnigraph repair s3://my-bucket/graph.omni
|
||||
omnigraph repair --confirm s3://my-bucket/graph.omni
|
||||
# For suspicious/unverifiable drift only after deliberate review:
|
||||
# omnigraph repair --force --confirm s3://my-bucket/graph.omni
|
||||
omnigraph cleanup --keep 10 --older-than 7d s3://my-bucket/graph.omni
|
||||
omnigraph cleanup --keep 10 --older-than 7d --confirm s3://my-bucket/graph.omni
|
||||
|
||||
# Stand up the HTTP server (token from env)
|
||||
OMNIGRAPH_SERVER_BEARER_TOKEN=xxxx \
|
||||
omnigraph-server --cluster s3://my-bucket/cluster --bind 0.0.0.0:8080
|
||||
omnigraph-server s3://my-bucket/graph.omni --bind 0.0.0.0:8080
|
||||
|
||||
# Cedar policy explain
|
||||
omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act-alice --action change --branch main
|
||||
omnigraph policy explain --actor act-alice --action change --branch main
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -250,11 +210,10 @@ omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act
|
|||
| Columnar storage on object store | ✅ Arrow/Lance | URI normalization, S3 env-var plumbing |
|
||||
| Per-dataset versioning + time travel | ✅ | `snapshot_at_version`, `entity_at`, snapshot-pinned reads across many tables |
|
||||
| Per-dataset branches | ✅ | **Graph-level** branches (atomic across all sub-tables), lazy fork, system branch filtering |
|
||||
| Atomic single-dataset commits | ✅ | **Multi-table publish via three layers**, NOT a single Lance primitive: (1) per-table Lance `commit_staged` for the data write, (2) `__manifest` row-level CAS via `ManifestBatchPublisher` for cross-table ordering, (3) the open-time recovery sweep for the residual gap between (1) and (2). All three layers ship; the five migrated writers (`MutationStaging::finalize`, `schema_apply`, `branch_merge`, `ensure_indices`, `optimize_all_tables`) write a `__recovery/{ulid}.json` sidecar before Phase B and delete it after Phase C. The next `Omnigraph::open` (gated on `OpenMode::ReadWrite`) runs the sweep in `db/manifest/recovery.rs`: classify, decide all-or-nothing per sidecar, roll forward via single `ManifestBatchPublisher::publish` or roll back via `Dataset::restore` followed by a manifest publish of the restored version (so both directions converge to `manifest == HEAD` — no residual drift), and record an audit row in `_graph_commit_recoveries.lance` (queryable via `omnigraph commit list --filter actor=omnigraph:recovery`). The write entry points (`load_as`, `mutate_as`, `apply_schema_as`, `branch_merge_as`) and `refresh` additionally run an in-process roll-forward-only heal (serialized against live writers via the per-table write queues), so a long-lived server converges on its next write without restart; only rollback-eligible sidecars still defer to the next read-write open (a future background reconciler's goal). Engine writes route through a sealed `TableStorage` trait (`db.storage()`) exposing only `stage_*` + `commit_staged` + reads; the inline-commit residuals (`delete_where`, `create_vector_index`) are split onto a separate sealed `InlineCommitResidual` trait reached via `db.storage_inline_residual()` (MR-854), so the default surface cannot couple a write with a HEAD advance — §1 holds by construction. `delete_where` and `create_vector_index` stay inline until upstream Lance ships a public two-phase API ([#6658](https://github.com/lance-format/lance/issues/6658), [#6666](https://github.com/lance-format/lance/issues/6666)); `LoadMode::Overwrite` uses Lance `Overwrite` staged transactions. |
|
||||
| Compaction (`compact_files`) + reindex (`optimize_indices`) | ✅ | `omnigraph optimize` orchestrates over all node/edge tables, bounded concurrency; per table runs `compact_files` **then Lance `optimize_indices`** (folds appended/rewritten fragments back into existing indexes — incremental merge, not retrain) and **publishes the resulting version to `__manifest`** (so the manifest tracks the Lance HEAD — required for reads to observe the work and for schema apply / strict writes to pass their HEAD-vs-manifest precondition), under the per-`(table, main)` write queue with `SidecarKind::Optimize` recovery coverage spanning both ops; **commits even with no compaction work if index coverage is stale**; **refuses on an unrecovered graph**; **skips uncovered HEAD > manifest drift** with `DriftNeedsRepair`; **skips blob-bearing tables** (reported via `TableOptimizeStats.skipped`, not silent; reindex is skipped for them too today), gated on `LANCE_SUPPORTS_BLOB_COMPACTION` until the upstream blob-v2 compaction-decode bug is fixed (see [docs/dev/invariants.md](docs/dev/invariants.md) Known Gaps) |
|
||||
| Repair uncovered drift | — | `omnigraph repair` explicitly classifies uncovered table `HEAD > manifest` drift: verified maintenance drift (`ReserveFragments`/`Rewrite`) can be published with `--confirm`; suspicious or unverifiable drift requires `--force --confirm`. Sidecar-covered crash residuals still recover automatically on open. |
|
||||
| Atomic single-dataset commits | ✅ | **Multi-table publish via three layers**, NOT a single Lance primitive: (1) per-table Lance `commit_staged` for the data write, (2) `__manifest` row-level CAS via `ManifestBatchPublisher` for cross-table ordering, (3) the open-time recovery sweep for the residual gap between (1) and (2). All three layers ship; the four migrated writers (`MutationStaging::finalize`, `schema_apply`, `branch_merge`, `ensure_indices`) write a `__recovery/{ulid}.json` sidecar before Phase B and delete it after Phase C. The next `Omnigraph::open` (gated on `OpenMode::ReadWrite`) runs the sweep in `db/manifest/recovery.rs`: classify, decide all-or-nothing per sidecar, roll forward via single `ManifestBatchPublisher::publish` or roll back via `Dataset::restore`, and record an audit row in `_graph_commit_recoveries.lance` (queryable via `omnigraph commit list --filter actor=omnigraph:recovery`). Continuous in-process recovery (no restart needed between Phase B failure and recovery) is the goal of a future background reconciler. Engine writes route through a sealed `TableStorage` trait exposing `stage_*` + `commit_staged` as the canonical staged-write surface; documented inline-commit residuals (`delete_where`, `create_vector_index`, plus legacy `append_batch` / `merge_insert_batches` / `overwrite_batch` / `create_*_index`) remain on the trait until upstream Lance ships a public two-phase API ([#6658](https://github.com/lance-format/lance/issues/6658), [#6666](https://github.com/lance-format/lance/issues/6666)) and the migration of every call site completes. |
|
||||
| Compaction (`compact_files`) | ✅ | `omnigraph optimize` orchestrates over all node/edge tables, bounded concurrency |
|
||||
| Cleanup (`cleanup_old_versions`) | ✅ | `omnigraph cleanup` with `--keep` / `--older-than` policy |
|
||||
| BTREE / inverted (FTS) / vector indexes | ✅ | `@index`/`@key` declares intent; the physical index is derived state that never fails a logical op. Built per column through one chokepoint (`build_indices_on_dataset_for_catalog`, type-dispatched by `node_prop_index_kind`: enum + orderable scalar → BTREE, free-text String → FTS, Vector → vector); idempotent; lazy across branches. **Schema apply builds nothing** (records intent only); `load`/`mutate` build inline but **defer an untrainable Vector column** (no trainable vectors yet) as *pending* rather than aborting. `ensure_indices`/`optimize` is the reconciler that materializes declared-but-missing indexes and restores coverage of appended/rewritten fragments (`optimize_indices`), reporting still-pending columns (see Compaction row). |
|
||||
| BTREE / inverted (FTS) / vector indexes | ✅ | `ensure_indices` builds them on every relevant column; idempotent; lazy across branches |
|
||||
| `merge_insert` upsert | ✅ | `LoadMode::Merge`, mutation `update`/`insert`/`delete` lowering |
|
||||
| Vector search | ✅ | `nearest()` query op; embedding pipeline (Gemini / OpenAI clients); `@embed` in schema |
|
||||
| Full-text search | ✅ | `search/fuzzy/match_text/bm25` query ops |
|
||||
|
|
@ -263,16 +222,15 @@ omnigraph policy explain --cluster ./company-brain --graph knowledge --actor act
|
|||
| Schema language | — | `.pg` + Pest grammar + catalog + interfaces + constraints + annotations |
|
||||
| Query language | — | `.gq` + Pest grammar + IR + lowering + linter |
|
||||
| Schema migration planning | — | `plan_schema_migration` + `apply_schema` step types + `__schema_apply_lock__` |
|
||||
| Commit graph (DAG) across whole graph | — | Lineage (linear + merge parents, ULID ids, actor) stored as `graph_commit`/`graph_head` rows in `__manifest`, written in the same publish CAS as the table-version rows (RFC-013 Phase 7 — no separate `_graph_commits.lance` write; manifest→commit-graph atomicity gap closed); the in-memory commit graph is a projection of those rows |
|
||||
| Commit graph (DAG) across whole graph | — | `_graph_commits.lance` with linear + merge parents, ULID ids, actor map |
|
||||
| Per-query atomic writes | — | In-memory `MutationStaging.pending` accumulator + `stage_*` / `commit_staged` per touched table at end-of-query + publisher CAS via `commit_with_expected` (single manifest commit per `mutate_as` / `load`); D₂ parse-time rule keeps inserts/updates and deletes from mixing |
|
||||
| Three-way row-level merge | — | `OrderedTableCursor` + `StagedTableWriter`, structured `MergeConflictKind` |
|
||||
| Change feeds | — | `diff_between` / `diff_commits` with manifest fast path + ID streaming |
|
||||
| Cedar policy | — | Per-graph actions plus server-scoped actions (see [docs/user/operations/policy.md](docs/user/operations/policy.md) for the current list), branch / target_branch / protected scopes, validate/test/explain CLI. **Engine-wide enforcement** (MR-722): every `_as` writer (`apply_schema_as`, `mutate_as`, `load_as` — the deprecated `ingest_as` shims route through it — `branch_create_as` / `branch_create_from_as`, `branch_delete_as`, `branch_merge_as`) calls `Omnigraph::enforce(action, scope, actor)` — HTTP, CLI, embedded SDK all hit the same gate. |
|
||||
| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **cluster-only boot (RFC-011): always `--cluster <dir | s3://…>`, serving N graphs (N ≥ 1) under multi-graph routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs via `cluster apply` and restart.** |
|
||||
| CLI with config | — | two-surface config (team `cluster.yaml` dir + per-operator `~/.omnigraph/config.yaml`), scope addressing (`--store`/`--server`/`--cluster`/`--profile`/defaults, RFC-011), aliases, multi-format output (json/jsonl/csv/kv/table) |
|
||||
| Cedar policy | — | Per-graph actions plus server-scoped actions (see [docs/user/policy.md](docs/user/policy.md) for the current list), branch / target_branch / protected scopes, validate/test/explain CLI. **Engine-wide enforcement** (MR-722): every `_as` writer (`apply_schema_as`, `mutate_as`, `load_as`, `ingest_as`, `branch_create_as` / `branch_create_from_as`, `branch_delete_as`, `branch_merge_as`) calls `Omnigraph::enforce(action, scope, actor)` — HTTP, CLI, embedded SDK all hit the same gate. |
|
||||
| HTTP server | — | Axum, OpenAPI via utoipa, bearer auth (SHA-256, AWS Secrets Manager option), `authorize_request` at the HTTP boundary (resolves bearer→actor, applies admission control), NDJSON streaming export, **multi-graph mode (v0.6.0+) with cluster routes + read-only `GET /graphs` enumeration + per-graph + server-level Cedar policies. Add/remove graphs by editing `omnigraph.yaml` and restarting.** |
|
||||
| CLI with config | — | `omnigraph.yaml`, aliases, multi-format output (json/jsonl/csv/kv/table) |
|
||||
| Audit / actor tracking | — | `_as` write APIs + actor map in commit graph |
|
||||
| Local S3 testing | — | run RustFS/MinIO + the `AWS_*` env; see [docs/user/deployment.md](docs/user/deployment.md) → *Testing against S3 locally* |
|
||||
| Agent skill | — | `skills/omnigraph` — operational playbook for driving Omnigraph; install with `npx skills add ModernRelay/omnigraph@omnigraph` |
|
||||
| Local RustFS bootstrap | — | `scripts/local-rustfs-bootstrap.sh` one-shot S3-backed dev environment |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -293,7 +251,7 @@ Rules:
|
|||
7. **Re-verify before recommending.** If you cite a flag, env var, endpoint, or constant to the user or in code, grep for it in source first. Memory and docs go stale; the code is authoritative.
|
||||
8. **Keep AGENTS.md short.** This file is always loaded into agent context, so every added line has a recurring context-window cost. Prefer pointers and terse invariants here; put detail in `docs/`.
|
||||
9. **Keep AGENTS.md a map, not an encyclopedia.** New deep content goes into `docs/`. Add an entry to "Where to find each topic" instead of pasting prose into this file. The "Always-on rules" section is the exception — it's for invariants that should always be in scope.
|
||||
10. **Re-read on schema/query/IR changes.** Edits to `schema.pest`, `query.pest`, `ir/lower.rs`, `query/typecheck.rs`, or `query/lint.rs` should trigger a re-read of [docs/user/schema/index.md](docs/user/schema/index.md), [docs/user/queries/index.md](docs/user/queries/index.md), and [docs/dev/execution.md](docs/dev/execution.md) to confirm they still describe reality.
|
||||
10. **Re-read on schema/query/IR changes.** Edits to `schema.pest`, `query.pest`, `ir/lower.rs`, `query/typecheck.rs`, or `query/lint.rs` should trigger a re-read of [docs/user/schema-language.md](docs/user/schema-language.md), [docs/user/query-language.md](docs/user/query-language.md), and [docs/dev/execution.md](docs/dev/execution.md) to confirm they still describe reality.
|
||||
11. **Always make smaller commits.** Each commit does one thing, compiles, and passes tests; mechanical refactors land separately from the behavior changes they enable.
|
||||
12. **Test-first for bug fixes.** When fixing an identified bug, write a regression test that reproduces the failure first. Confirm it fails against the current code with the predicted symptom (not an unrelated error). Then land the fix in a separate commit and confirm the test turns green. The test commit lands just before the fix commit so the red → green pair is visible in `git log` and a reviewer can check out the test commit alone and reproduce the failure.
|
||||
13. **Correct by design over symptomatic patches.** When a bug surfaces, identify the root cause and make the fix correct by construction. Don't patch the symptom. If the design admits the bug class, the fix is to close the class, not to add a guard around the latest instance. A symptomatic patch is acceptable only as a stop-gap, with an explicit note in the commit message and a follow-up issue tracking the design fix.
|
||||
|
|
|
|||
|
|
@ -1,29 +1,10 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for your interest in OmniGraph. This page is the practical how-to; the
|
||||
rules and decision authority behind it live in [GOVERNANCE.md](GOVERNANCE.md).
|
||||
Small bug fixes and documentation improvements are welcome directly through pull
|
||||
requests.
|
||||
|
||||
## Start in the right place
|
||||
|
||||
| I want to… | Go to | Notes |
|
||||
|---|---|---|
|
||||
| **Report a bug** or wrong behavior | **[Open an Issue](../../issues/new/choose)** | Concrete and reproducible. A maintainer triages it; once labelled **`accepted`** it's open for a PR. |
|
||||
| **Suggest a feature / share an idea / ask** | **[Start a Discussion](../../discussions)** | Ideas and questions live here, not in Issues. |
|
||||
| **Propose a design / RFC** | **An RFC pull request** | Anyone can author one — see [docs/rfcs/README.md](docs/rfcs/README.md). A maintainer merging it is acceptance. |
|
||||
| **Fix something / implement a change** | **A pull request** | Must link an `accepted` issue or an accepted RFC — unless it's trivial (below). |
|
||||
| **Report a security vulnerability** | **[SECURITY.md](SECURITY.md)** | Do **not** open a public Issue. |
|
||||
|
||||
### When can I just open a PR?
|
||||
The **trivial fast-lane** — open directly, no prior issue/RFC needed: typo and
|
||||
wording fixes, doc corrections, dependency bumps, comment fixes, obvious
|
||||
one-line CI tweaks. Anything more substantial needs a backing `accepted` issue
|
||||
or accepted RFC first, so the *why* is agreed before the *how* is reviewed. A PR
|
||||
that turns out to be non-trivial will be redirected — that's about process, not
|
||||
the merit of the change.
|
||||
|
||||
> **Maintainers (ModernRelay team)** follow a separate internal process and are
|
||||
> not bound by the intake rules above. Everyone is bound by review, branch
|
||||
> protection, and CI.
|
||||
For larger changes, please open an issue or design discussion first so the
|
||||
proposed direction is clear before implementation starts.
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -68,11 +49,6 @@ CI runs both.
|
|||
|
||||
## Pull Requests
|
||||
|
||||
- **Link the backing issue or RFC** (`Closes #123`, or reference the RFC) — or
|
||||
mark the PR as trivial per the fast-lane.
|
||||
- Keep changes focused; one logical change per PR.
|
||||
- Include tests for behavior changes when practical.
|
||||
- Update public docs when the user-facing surface changes.
|
||||
|
||||
New to the codebase? Read [AGENTS.md](AGENTS.md) — the architecture map and the
|
||||
always-on invariants every change is reviewed against.
|
||||
- keep changes focused
|
||||
- include tests for behavior changes when practical
|
||||
- update public docs when the user-facing surface changes
|
||||
|
|
|
|||
1738
Cargo.lock
generated
1738
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
22
Cargo.toml
22
Cargo.toml
|
|
@ -4,8 +4,6 @@ members = [
|
|||
"crates/omnigraph-compiler",
|
||||
"crates/omnigraph",
|
||||
"crates/omnigraph-cli",
|
||||
"crates/omnigraph-api-types",
|
||||
"crates/omnigraph-cluster",
|
||||
"crates/omnigraph-policy",
|
||||
"crates/omnigraph-server",
|
||||
]
|
||||
|
|
@ -31,14 +29,14 @@ datafusion-common = "53"
|
|||
datafusion-expr = "53"
|
||||
datafusion-functions-aggregate = "53"
|
||||
|
||||
lance = { version = "7.0.0", default-features = false, features = ["aws"] }
|
||||
lance-datafusion = "7.0.0"
|
||||
lance-file = "7.0.0"
|
||||
lance-index = "7.0.0"
|
||||
lance-linalg = "7.0.0"
|
||||
lance-namespace = "7.0.0"
|
||||
lance-namespace-impls = "7.0.0"
|
||||
lance-table = "7.0.0"
|
||||
lance = { version = "6.0.1", default-features = false, features = ["aws"] }
|
||||
lance-datafusion = "6.0.1"
|
||||
lance-file = "6.0.1"
|
||||
lance-index = "6.0.1"
|
||||
lance-linalg = "6.0.1"
|
||||
lance-namespace = "6.0.1"
|
||||
lance-namespace-impls = "6.0.1"
|
||||
lance-table = "6.0.1"
|
||||
|
||||
ulid = "1"
|
||||
futures = "0.3"
|
||||
|
|
@ -48,7 +46,7 @@ pest = "2"
|
|||
pest_derive = "2"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net", "signal", "sync"] }
|
||||
clap = { version = "4.6", features = ["derive"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
|
|
@ -64,7 +62,7 @@ base64 = "0.22"
|
|||
ariadne = "0.4"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
object_store = { version = "0.13.2", default-features = false, features = ["aws", "fs"] }
|
||||
object_store = { version = "0.12.5", default-features = false, features = ["aws"] }
|
||||
fail = "0.5"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
axum = { version = "0.8", features = ["json", "macros"] }
|
||||
|
|
|
|||
|
|
@ -11,13 +11,9 @@ RUN groupadd --system omnigraph \
|
|||
&& useradd --system --gid omnigraph --create-home --home-dir /var/lib/omnigraph omnigraph
|
||||
|
||||
COPY target/release/omnigraph-server /usr/local/bin/omnigraph-server
|
||||
# The CLI ships in the image so the cluster day-2 loop (cluster
|
||||
# apply/approve/status, data loads by explicit URI) runs in-container via
|
||||
# `docker exec` / ECS exec / `railway shell` — no omnigraph.yaml required.
|
||||
COPY target/release/omnigraph /usr/local/bin/omnigraph
|
||||
COPY docker/entrypoint.sh /usr/local/bin/omnigraph-entrypoint
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/omnigraph-server /usr/local/bin/omnigraph /usr/local/bin/omnigraph-entrypoint
|
||||
RUN chmod 0755 /usr/local/bin/omnigraph-server /usr/local/bin/omnigraph-entrypoint
|
||||
|
||||
ENV OMNIGRAPH_BIND=0.0.0.0:8080
|
||||
|
||||
|
|
|
|||
105
GOVERNANCE.md
105
GOVERNANCE.md
|
|
@ -1,105 +0,0 @@
|
|||
# Governance
|
||||
|
||||
This document describes how **external contributions** to OmniGraph are
|
||||
proposed, accepted, and merged. It exists so an outside contributor can answer,
|
||||
without asking: *where does my report/idea/change go, who decides, and what has
|
||||
to happen before code lands?*
|
||||
|
||||
> **Scope.** This governs the public contribution surface — Issues,
|
||||
> Discussions, RFCs, and pull requests from people outside the ModernRelay
|
||||
> team. **Maintainers operate under a separate internal process** and are not
|
||||
> bound by the intake gates below. Everyone, maintainer or not, is still bound
|
||||
> by the universal gates: branch protection on `main` and CI
|
||||
> (see [docs/dev/branch-protection.md](docs/dev/branch-protection.md)).
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Who | Authority |
|
||||
|---|---|---|
|
||||
| **Maintainer** | The ModernRelay team (repository admins) | Validate issues, accept/reject RFCs, review and merge PRs, set direction. Final decision authority. |
|
||||
| **Contributor** | Anyone else | Report problems (Issues), propose ideas (Discussions), author RFCs, and open pull requests. |
|
||||
|
||||
Decision authority rests with the maintainers (the ModernRelay team holding
|
||||
repository-admin access).
|
||||
|
||||
## The three channels
|
||||
|
||||
Each channel has one job. Using the right one is the first thing we ask of a
|
||||
contribution.
|
||||
|
||||
| Channel | Purpose | Not for |
|
||||
|---|---|---|
|
||||
| **[Issues](../../issues)** | **Report a problem** — a bug, a regression, a documented behavior that's wrong. Something concrete and reproducible. | Feature requests, ideas, questions, or design proposals (→ Discussions). |
|
||||
| **[Discussions](../../discussions)** | **Propose and explore** — new ideas, feature requests, questions, and the incubation of RFCs. | Bug reports (→ Issues). |
|
||||
| **Pull requests** | **Land a sanctioned change** — a fix for a *validated* issue, an *accepted* RFC, or a trivial change (see fast-lane). | Substantive change with no backing issue/RFC — it will be redirected. |
|
||||
|
||||
## How a change becomes mergeable
|
||||
|
||||
```
|
||||
┌─────────── bug ───────────┐ ┌──────── idea / feature ────────┐
|
||||
▼ │ ▼ │
|
||||
Issue (problem report) │ Discussion (idea / RFC incubation) │
|
||||
│ │ │ │
|
||||
maintainer triage │ rough consensus │
|
||||
│ │ │ graduate │
|
||||
▼ │ ▼ │
|
||||
label: accepted ──────────┐ │ RFC PR (docs/rfcs/NNNN-*.md) │
|
||||
│ │ │ │ │
|
||||
│ │ │ maintainer review │
|
||||
▼ ▼ │ ▼ │
|
||||
Pull request ◀──────────┴──────────│── merged == accepted │
|
||||
(links the issue or the accepted RFC) ◀───────┘ (implementation PRs reference it) │
|
||||
│
|
||||
review + branch protection + CI
|
||||
▼
|
||||
merged
|
||||
```
|
||||
|
||||
### Issues → validated
|
||||
A new issue starts unlabeled. A maintainer triages it and, if it's a real,
|
||||
in-scope problem, applies the **`accepted`** label. **Only `accepted` issues are
|
||||
open for a contributor PR.** This prevents the "I fixed an issue you hadn't
|
||||
agreed was a problem" rejection. Want to fix something? Get the issue accepted
|
||||
first, or pick one already labelled `accepted` / `help wanted`.
|
||||
|
||||
### Discussions → RFCs → accepted
|
||||
Ideas and feature requests start in **Discussions**. Anyone — including external
|
||||
contributors — may then **author an RFC** by opening a pull request that adds
|
||||
`docs/rfcs/NNNN-title.md` (see [docs/rfcs/README.md](docs/rfcs/README.md)). The
|
||||
RFC is reviewed as code; **a maintainer merging it is the act of acceptance**
|
||||
(it becomes the durable decision record). Implementation PRs then reference the
|
||||
accepted RFC.
|
||||
|
||||
Authoring an RFC is open to everyone; **accepting one is a maintainer
|
||||
decision.** Maintainers may also decline an RFC, with rationale, by closing it.
|
||||
|
||||
### Pull requests → sanctioned
|
||||
A contributor PR must do one of:
|
||||
1. link a maintainer-**`accepted`** issue it fixes, or
|
||||
2. be (or reference) an **accepted RFC**, or
|
||||
3. qualify for the **trivial fast-lane**.
|
||||
|
||||
**Trivial fast-lane** — these may be opened directly, no prior issue/RFC:
|
||||
typo and wording fixes, documentation corrections, dependency bumps, comment
|
||||
fixes, and obviously-correct one-line CI tweaks. When in doubt, open an Issue or
|
||||
Discussion first; a PR that turns out to be non-trivial will be asked to.
|
||||
|
||||
A substantive PR with no backing issue/RFC will be closed with a pointer to the
|
||||
right channel — not as a judgment of the idea, but to keep design discussion
|
||||
where it's reviewable.
|
||||
|
||||
## What maintainers do *not* gate
|
||||
Maintainers' own changes do not pass through the intake gates above — the team
|
||||
runs a separate internal process. The universal gates (review, branch
|
||||
protection, CI) apply to everyone. Enforcement of the intake rules is, to
|
||||
start, **by convention and review** (PR template + labels); an automated check
|
||||
keyed to author association may be added later if volume warrants.
|
||||
|
||||
## Code of conduct & security
|
||||
- Conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||
- Security issues are **not** public Issues — see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Changing this document
|
||||
Governance changes the same way code does: a pull request, reviewed by
|
||||
maintainers. This file describes the external surface; the internal maintainer
|
||||
process is intentionally out of scope here.
|
||||
255
README.md
255
README.md
|
|
@ -1,233 +1,105 @@
|
|||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/omnigraph-wordmark-dark.svg">
|
||||
<img alt="OMNIGRAPH" src="assets/omnigraph-wordmark.svg" width="420">
|
||||
</picture>
|
||||
</p>
|
||||
# Omnigraph
|
||||
|
||||
<p align="center">
|
||||
<strong>Lakehouse graph database for context assembly & multi-agent coordination</strong><br>
|
||||
<sub>Multimodal retrieval · Git-style branching · object-storage native</sub>
|
||||
</p>
|
||||
[](LICENSE)
|
||||
[](rust-toolchain.toml)
|
||||
[](https://crates.io/crates/omnigraph-cli)
|
||||
[](https://github.com/ModernRelay/omnigraph/actions/workflows/ci.yml)
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/user/quickstart.md">Quickstart</a> ·
|
||||
<a href="docs/user/clusters/index.md">Docs</a> ·
|
||||
<a href="https://github.com/ModernRelay/omnigraph-cookbooks">Cookbooks</a> ·
|
||||
<a href="docs/user/cli/reference.md">CLI</a>
|
||||
</p>
|
||||
**Object-storage native knowledge graph with git-style workflows. Designed for agents and humans to collaborate on shared structured knowledge.**
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-1b1b1f?style=flat-square&labelColor=1b1b1f"></a>
|
||||
<a href="https://crates.io/crates/omnigraph-cli"><img alt="crates.io" src="https://img.shields.io/crates/v/omnigraph-cli?style=flat-square&color=d71921&labelColor=1b1b1f"></a>
|
||||
<a href="rust-toolchain.toml"><img alt="Rust" src="https://img.shields.io/badge/rust-stable-1b1b1f?style=flat-square&labelColor=1b1b1f"></a>
|
||||
</p>
|
||||
Turns fragmented context into a live graph, lets humans and agents coordinate through that graph, and uses branches so agent-generated changes can be reviewed and merged safely.
|
||||
|
||||
<hr>
|
||||
Built on Rust, Arrow, DataFusion and Lance.
|
||||
|
||||
Omnigraph is the operational state and coordination layer for fleets of agents.\
|
||||
Run it as a server, declared as code; hundreds of agents operate and enrich the graph on parallel isolated branches, and every change is reviewed and merged safely.
|
||||
Join the [Omnigraph Slack community](https://join.slack.com/t/omnigraphworkspace/shared_invite/zt-3wfpglyxj-lHvJGhuySPfqLtN35uJZNw)
|
||||
|
||||
## Key capabilities
|
||||
## Use Cases
|
||||
|
||||
| Capability | What it gives you |
|
||||
|---|---|
|
||||
| **Declared as code** | A `cluster.yaml` declares graphs, schemas, stored queries, embedding providers, and policies; `cluster apply` converges it and `omnigraph-server` brings every graph online at `/graphs/{id}/…`. |
|
||||
| **Built for fleets of agents** | Hundreds of agents enrich the graph on **parallel isolated branches**; changes are reviewed and merged safely, Git-style, across the whole graph. |
|
||||
| **Multimodal retrieval** | Graph traversal + vector ANN + full-text + Reciprocal Rank Fusion in **one** query runtime, for context assembly. |
|
||||
| **Security as code** | Cedar policy enforced **server-side on every mutation**, per-graph and server-wide; bearer auth; actor/audit tracking. |
|
||||
| **Runs on your infrastructure** | Any S3-compatible object store: **on-prem via RustFS / MinIO**, or AWS S3 / R2 / GCS. VPC, on-prem, hybrid; your data never leaves your store. |
|
||||
| **Open, versioned storage** | [`Lance`](https://github.com/lance-format/lance) columnar format: branchable, time-travelable, with native blob-as-data (docs, images, video). |
|
||||
- Company brain / [Second brain](https://github.com/ModernRelay/omnigraph-cookbooks/tree/main/second-brain)
|
||||
- Context graph
|
||||
- Knowledge base for multi-agent research
|
||||
- Incident response graph
|
||||
- Compliance & audit graph
|
||||
|
||||
## What you can build
|
||||
|
||||
| Use case | What it's for |
|
||||
|---|---|
|
||||
| **Company brain** | Org knowledge unified into one graph every agent can query |
|
||||
| **Agentic memory** | Durable, versioned memory: a branch per agent or per task, merged on review |
|
||||
| **Context graph** | Decision traces and codified tribal knowledge for retrieval |
|
||||
| **Dev graph** | Issues & dependency model that coding agents read and write |
|
||||
| **R&D / ML data layer** | Experiments and trials written into branches, versioned for training & eval |
|
||||
## Capabilities
|
||||
|
||||
## Install
|
||||
- Typed schema, typed queries, and typed mutations
|
||||
- Native blob-as-data support (docs, images, videos, etc)
|
||||
- Schema-as-code, query validation and linting
|
||||
- Git-style graph workflows: branches, commits, merges, and transactional runs
|
||||
- Local, on-prem & cloud S3-native storage with snapshot-pinned reads
|
||||
- Graph traversal + text, fuzzy, BM25, vector, and RRF search in one runtime
|
||||
- Policy-as-code for server-side access control
|
||||
- Single CLI for multiple deployments
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ModernRelay/omnigraph/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
This installs `omnigraph` (CLI) and `omnigraph-server` into `~/.local/bin` from
|
||||
published release binaries. Or with Homebrew:
|
||||
This installs `omnigraph` and `omnigraph-server` into `~/.local/bin` from
|
||||
published release binaries.
|
||||
|
||||
Or install with Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap ModernRelay/tap
|
||||
brew install ModernRelay/tap/omnigraph
|
||||
```
|
||||
|
||||
## Set it up with an AI agent
|
||||
For starter graphs and agent skills to bootstrap and operate Omnigraph, see [`ModernRelay/omnigraph-cookbooks`](https://github.com/ModernRelay/omnigraph-cookbooks).
|
||||
|
||||
Omnigraph is built to be run by coding agents. Two ways in:
|
||||
|
||||
**Teach your agent the playbook.** This repo ships the
|
||||
[**`omnigraph` agent skill**](skills/omnigraph): the operational playbook
|
||||
covering cluster mode, the two config surfaces, schema evolution, query linting,
|
||||
data writes, branches, Cedar policy, and the common gotchas.
|
||||
## One-Command Local RustFS Bootstrap
|
||||
|
||||
```bash
|
||||
npx skills add ModernRelay/omnigraph@omnigraph
|
||||
curl -fsSL https://raw.githubusercontent.com/ModernRelay/omnigraph/main/scripts/local-rustfs-bootstrap.sh | bash
|
||||
```
|
||||
|
||||
**Or have an agent set it up from scratch.** Paste this into Claude Code,
|
||||
Codex, or any agent that can read a URL and run a shell command:
|
||||
That bootstrap:
|
||||
|
||||
```text
|
||||
Help me set up Omnigraph
|
||||
- starts RustFS on `127.0.0.1:9000`
|
||||
- creates a bucket and S3-backed graph
|
||||
- loads the checked-in context fixture
|
||||
- launches `omnigraph-server` on `127.0.0.1:8080`
|
||||
|
||||
1. Read the docs at https://github.com/ModernRelay/omnigraph, starting with
|
||||
docs/user/clusters/index.md, then docs/user/deployment.md.
|
||||
2. Skim the starter graphs and seed data in the cookbooks:
|
||||
https://github.com/ModernRelay/omnigraph-cookbooks
|
||||
3. Ask me what I want to build (company brain, agent memory, dev graph,
|
||||
research / R&D layer, …). Then stand up a cluster for it, load a little
|
||||
data, and run a query so I can see it working.
|
||||
```
|
||||
Docker must be installed and running first.
|
||||
|
||||
For ready-to-run graphs with real seed data (company brain, VC operating system,
|
||||
pharma & industry intel),
|
||||
[`ModernRelay/omnigraph-cookbooks`](https://github.com/ModernRelay/omnigraph-cookbooks)
|
||||
is the fastest way to see Omnigraph shaped to a real domain.
|
||||
The RustFS bootstrap prefers the rolling `edge` binaries and only falls back to
|
||||
source builds when release assets are unavailable.
|
||||
|
||||
## Deploy
|
||||
If a previous run left objects under the same graph prefix but did not finish
|
||||
initializing the graph, rerun with `RESET_REPO=1` or set `PREFIX` to a new
|
||||
value.
|
||||
|
||||
A deployment is a **cluster**: a **multigraph** config directory that declares
|
||||
its graphs, schemas, stored queries, and policies as code. You manage it
|
||||
**Terraform-style**: `cluster plan` previews the diff, `cluster apply` converges
|
||||
it. `omnigraph-server` then boots from the cluster and brings every graph online
|
||||
at `/graphs/{id}/…`, each behind its own policy.
|
||||
## Common Commands
|
||||
|
||||
**1. Declare the cluster.**
|
||||
|
||||
```
|
||||
company-brain/
|
||||
├── cluster.yaml
|
||||
├── people.pg # schema for the "knowledge" graph
|
||||
├── queries/ # stored queries: the .gq files ARE the declaration
|
||||
│ └── people.gq
|
||||
└── base.policy.yaml # a Cedar policy bundle
|
||||
```
|
||||
|
||||
```yaml
|
||||
# cluster.yaml
|
||||
version: 1
|
||||
metadata:
|
||||
name: company-brain
|
||||
storage: s3://company/clusters/company-brain # ledger, catalog, and graph data live here
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: people.pg
|
||||
queries: queries/ # every `query <name>` in queries/*.gq registers
|
||||
policies:
|
||||
base:
|
||||
file: base.policy.yaml
|
||||
applies_to: [knowledge] # graph-bound; use [cluster] for server-level
|
||||
```
|
||||
|
||||
**2. Stand up your object store.** On-prem, run RustFS (or MinIO); Omnigraph
|
||||
writes [Lance](https://github.com/lance-format/lance) to it over the standard S3
|
||||
API. In the cloud, point the same `AWS_*` env at S3 / R2 / GCS instead.
|
||||
|
||||
**3. Converge and run.** `apply` creates each graph, applies its schema, and
|
||||
publishes queries and policies into the content-addressed catalog. It is
|
||||
idempotent; re-running is always safe.
|
||||
The same URI works for local paths, `s3://…`, or `http://host:port`.
|
||||
|
||||
```bash
|
||||
omnigraph cluster validate # parse + typecheck everything
|
||||
omnigraph cluster plan # preview what apply would do
|
||||
omnigraph cluster apply # converge
|
||||
|
||||
# Boot the server from the cluster dir; storage resolves through cluster.yaml
|
||||
omnigraph-server --cluster company-brain --bind 0.0.0.0:8080
|
||||
omnigraph init --schema ./schema.pg ./graph.omni
|
||||
omnigraph load --data ./data.jsonl ./graph.omni
|
||||
omnigraph read --query ./queries.gq --name get_person --params '{"name":"Alice"}' ./graph.omni
|
||||
omnigraph change --query ./queries.gq --name insert_person --params '{"name":"Mina"}' ./graph.omni
|
||||
omnigraph branch create --from main feature-x ./graph.omni
|
||||
omnigraph branch merge feature-x --into main ./graph.omni
|
||||
```
|
||||
|
||||
See the [cluster guide](docs/user/clusters/index.md) for the day-2 loop
|
||||
(edit → plan → apply → restart), approval gates for destructive changes, drift
|
||||
inspection, and recovery; the [deployment guide](docs/user/deployment.md) for
|
||||
containers, AWS/Railway, auth, and the full `AWS_*` contract.
|
||||
|
||||
## Query and mutate
|
||||
|
||||
Set a default server and graph once in `~/.omnigraph/config.yaml`, and the
|
||||
everyday commands stay short. Stored queries and mutations run **by name**:
|
||||
|
||||
```bash
|
||||
omnigraph query search_docs --params '{"q":"AI safety"}'
|
||||
omnigraph mutate add_person --params '{"name":"Mina"}'
|
||||
|
||||
# Branch, review, merge across the whole graph; agents write in isolation
|
||||
omnigraph branch create --from main agent/ingest-42
|
||||
omnigraph branch merge agent/ingest-42 --into main
|
||||
```
|
||||
|
||||
An **alias** is shorter still: bind a server, graph, and stored query to one
|
||||
name, then `omnigraph alias triage` runs it. For an ad-hoc target, any command
|
||||
still takes `--server <name|url> --graph <id>` (or `--store <uri>` for a local
|
||||
graph). See the [CLI reference](docs/user/cli/reference.md).
|
||||
|
||||
## Security & governance
|
||||
|
||||
- **Engine-wide enforcement:** every write path goes through the same Cedar gate, so the HTTP server, the CLI, and the embedded SDK obey identical rules.
|
||||
- **Declared in the cluster:** a policy bundle is bound to graphs (or the whole server) via `policies:` → `applies_to`.
|
||||
- **Scoped:** rules apply per graph, per branch, or server-wide.
|
||||
- **No plaintext tokens:** bearer tokens are hashed at startup and compared in constant time.
|
||||
- **Forge-proof identity:** the actor is resolved server-side from the token; clients can't set it.
|
||||
|
||||
See the [policy guide](docs/user/operations/policy.md).
|
||||
|
||||
## Clients & SDKs
|
||||
|
||||
| Client | Use it for | Where |
|
||||
|---|---|---|
|
||||
| **TypeScript SDK** | typed access from Node / TS | [`@modernrelay/omnigraph`](https://www.npmjs.com/package/@modernrelay/omnigraph) · [source](https://github.com/ModernRelay/omnigraph-ts) |
|
||||
| **MCP server** | bridge Omnigraph to LLM hosts (Claude, Codex, …) | [`@modernrelay/omnigraph-mcp`](https://www.npmjs.com/package/@modernrelay/omnigraph-mcp) |
|
||||
| **HTTP / OpenAPI** | any language, the wire contract | the server's OpenAPI spec |
|
||||
| **Python SDK** | typed access from Python | *coming soon* |
|
||||
|
||||
Both npm packages are versioned in lockstep with `omnigraph-server`.
|
||||
|
||||
## Local quick test (no server)
|
||||
|
||||
1-min setup to try it: an **embedded, local file-backed graph** (no server, no
|
||||
object store). For dev and experiments; production is the deployed cluster above.
|
||||
|
||||
```bash
|
||||
cat > schema.pg <<'PG'
|
||||
node Signal { slug: String @key, title: String }
|
||||
node Pattern { slug: String @key, name: String }
|
||||
edge Indicates: Signal -> Pattern
|
||||
PG
|
||||
printf '%s\n' \
|
||||
'{"type":"Signal","data":{"slug":"s1","title":"OSS model adoption surging"}}' \
|
||||
'{"type":"Pattern","data":{"slug":"p1","name":"adoption"}}' \
|
||||
'{"edge":"Indicates","from":"s1","to":"p1"}' > data.jsonl
|
||||
|
||||
omnigraph init --schema schema.pg ./graph.omni
|
||||
omnigraph load --data data.jsonl --mode overwrite --store ./graph.omni
|
||||
|
||||
# "What pattern does signal s1 indicate?"
|
||||
omnigraph query --store ./graph.omni \
|
||||
-e 'query indicates() { match { $s: Signal { slug: "s1" } $s indicates $p } return { $p.name } }'
|
||||
# → adoption
|
||||
```
|
||||
See [docs/user/cli.md](docs/user/cli.md) for schema apply, snapshots, ingest, runs, and policy commands.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Cluster guide](docs/user/clusters/index.md) · [Deployment guide](docs/user/deployment.md) · [CLI reference](docs/user/cli/reference.md)
|
||||
- [Schema](docs/user/schema/index.md) · [Queries](docs/user/queries/index.md) · [Search](docs/user/search/index.md) · [Policy](docs/user/operations/policy.md)
|
||||
- [Install guide](docs/user/install.md)
|
||||
- [CLI guide](docs/user/cli.md)
|
||||
- [Deployment guide](docs/user/deployment.md)
|
||||
|
||||
## Build And Test
|
||||
|
||||
```bash
|
||||
cargo build --workspace
|
||||
cargo test --workspace
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
|
@ -239,13 +111,10 @@ Notes:
|
|||
|
||||
## Workspace Crates
|
||||
|
||||
- `crates/omnigraph-compiler`: shared schema/query parser, typechecker, catalog, and IR lowering (zero Lance dependency)
|
||||
- `crates/omnigraph` (package `omnigraph-engine`): storage/runtime, branching, merge, change detection, query execution, and embeddings
|
||||
- `crates/omnigraph-policy`: Cedar policy compilation and enforcement
|
||||
- `crates/omnigraph-api-types`: shared HTTP wire DTOs used by both the server and the CLI
|
||||
- `crates/omnigraph-cluster`: cluster config validation, planning, and apply (the control plane)
|
||||
- `crates/omnigraph-server`: Axum HTTP server, cluster-first, runs N graphs under `/graphs/{id}/…`
|
||||
- `crates/omnigraph-cli`: CLI for graph lifecycle, query/mutate, branch/commit/merge, schema/lint, snapshot/export, cluster control, policy/queries, profiles, and maintenance
|
||||
- `crates/omnigraph-compiler`: shared schema/query parser, typechecker, catalog, and IR lowering
|
||||
- `crates/omnigraph`: storage/runtime, branching, merge, change detection, and query execution
|
||||
- `crates/omnigraph-cli`: CLI for init/load/ingest/read/change/branch/snapshot/export/policy operations
|
||||
- `crates/omnigraph-server`: Axum HTTP server for remote reads, changes, ingest, export, branches, commits, and runs
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="757" height="113" viewBox="0 0 757 113" fill="#f5f5f5" role="img" aria-label="OMNIGRAPH">
|
||||
<circle cx="28.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="42.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="56.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="28.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="42.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="56.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="112.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="140.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="126.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="126.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="196.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="196.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="210.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="224.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="224.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="280.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="308.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="280.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="308.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="364.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="378.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="392.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="378.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="392.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="364.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="378.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="392.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="448.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="462.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="476.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="490.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="490.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="448.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="462.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="476.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="462.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="476.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="490.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="532.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="546.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="560.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="532.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="546.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="560.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="616.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="630.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="644.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="658.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="658.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="616.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="630.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="644.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="700.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="714.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="728.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="98.6" r="4.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
|
|
@ -1,152 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="757" height="113" viewBox="0 0 757 113" fill="#1b1b1f" role="img" aria-label="OMNIGRAPH">
|
||||
<circle cx="28.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="42.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="56.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="14.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="70.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="28.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="42.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="56.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="112.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="140.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="126.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="126.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="98.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="154.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="196.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="196.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="210.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="224.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="224.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="182.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="238.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="280.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="308.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="280.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="294.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="308.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="364.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="378.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="392.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="378.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="392.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="350.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="364.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="378.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="392.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="406.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="448.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="462.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="476.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="490.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="490.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="448.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="462.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="476.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="462.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="476.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="434.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="490.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="532.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="546.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="560.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="532.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="546.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="560.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="518.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="574.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="616.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="630.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="644.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="658.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="658.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="616.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="630.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="644.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="602.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="14.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="28.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="42.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="700.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="714.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="728.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="56.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="70.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="84.6" r="4.6"/>
|
||||
<circle cx="686.6" cy="98.6" r="4.6"/>
|
||||
<circle cx="742.6" cy="98.6" r="4.6"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.1 KiB |
|
|
@ -1,16 +0,0 @@
|
|||
[package]
|
||||
name = "omnigraph-api-types"
|
||||
version = "0.7.2"
|
||||
edition = "2024"
|
||||
description = "Shared HTTP wire DTOs for Omnigraph — request/response types and engine-result → DTO mappings used by both omnigraph-server and omnigraph-cli (RFC-009). Plain serde/utoipa types; no transport or server internals."
|
||||
license = "MIT"
|
||||
repository = "https://github.com/ModernRelay/omnigraph"
|
||||
homepage = "https://github.com/ModernRelay/omnigraph"
|
||||
documentation = "https://docs.rs/omnigraph-api-types"
|
||||
|
||||
[dependencies]
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.7.2" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.2" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
|
|
@ -1,704 +0,0 @@
|
|||
//! Shared HTTP wire DTOs (RFC-009 Phase 2) — moved from
|
||||
//! omnigraph-server's api module so server and CLI share one definition
|
||||
//! and one engine-result -> DTO mapping per verb. Plain serde/utoipa
|
||||
//! types; no transport, no server internals.
|
||||
|
||||
use omnigraph::db::{GraphCommit, MergeOutcome, ReadTarget, SchemaApplyResult, Snapshot};
|
||||
use omnigraph::error::{MergeConflict, MergeConflictKind};
|
||||
use omnigraph::loader::{LoadMode, LoadResult};
|
||||
use omnigraph_compiler::SchemaMigrationStep;
|
||||
use omnigraph_compiler::query::ast::Param;
|
||||
use omnigraph_compiler::result::QueryResult;
|
||||
use omnigraph_compiler::types::{PropType, ScalarType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
/// Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.
|
||||
#[derive(ToSchema)]
|
||||
#[schema(as = LoadMode)]
|
||||
#[allow(dead_code)]
|
||||
enum LoadModeSchema {
|
||||
/// Overwrite existing data.
|
||||
#[schema(rename = "overwrite")]
|
||||
Overwrite,
|
||||
/// Append to existing data.
|
||||
#[schema(rename = "append")]
|
||||
Append,
|
||||
/// Merge by id key (upsert).
|
||||
#[schema(rename = "merge")]
|
||||
Merge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SnapshotTableOutput {
|
||||
pub table_key: String,
|
||||
pub table_path: String,
|
||||
pub table_version: u64,
|
||||
pub table_branch: Option<String>,
|
||||
pub row_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SnapshotOutput {
|
||||
pub branch: String,
|
||||
pub manifest_version: u64,
|
||||
pub tables: Vec<SnapshotTableOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchCreateRequest {
|
||||
/// Parent branch to fork from. Defaults to `main`.
|
||||
pub from: Option<String>,
|
||||
/// Name of the new branch. Must not already exist.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchCreateOutput {
|
||||
pub uri: String,
|
||||
pub from: String,
|
||||
pub name: String,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchListOutput {
|
||||
pub branches: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchDeleteOutput {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchMergeRequest {
|
||||
/// Source branch whose commits will be merged.
|
||||
pub source: String,
|
||||
/// Target branch that will receive the merge. Defaults to `main`.
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BranchMergeOutcome {
|
||||
AlreadyUpToDate,
|
||||
FastForward,
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl From<MergeOutcome> for BranchMergeOutcome {
|
||||
fn from(value: MergeOutcome) -> Self {
|
||||
match value {
|
||||
MergeOutcome::AlreadyUpToDate => Self::AlreadyUpToDate,
|
||||
MergeOutcome::FastForward => Self::FastForward,
|
||||
MergeOutcome::Merged => Self::Merged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BranchMergeOutcome {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AlreadyUpToDate => "already_up_to_date",
|
||||
Self::FastForward => "fast_forward",
|
||||
Self::Merged => "merged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchMergeOutput {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub outcome: BranchMergeOutcome,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MergeConflictKindOutput {
|
||||
DivergentInsert,
|
||||
DivergentUpdate,
|
||||
DeleteVsUpdate,
|
||||
OrphanEdge,
|
||||
UniqueViolation,
|
||||
CardinalityViolation,
|
||||
ValueConstraintViolation,
|
||||
}
|
||||
|
||||
impl MergeConflictKindOutput {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::DivergentInsert => "divergent_insert",
|
||||
Self::DivergentUpdate => "divergent_update",
|
||||
Self::DeleteVsUpdate => "delete_vs_update",
|
||||
Self::OrphanEdge => "orphan_edge",
|
||||
Self::UniqueViolation => "unique_violation",
|
||||
Self::CardinalityViolation => "cardinality_violation",
|
||||
Self::ValueConstraintViolation => "value_constraint_violation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeConflictKind> for MergeConflictKindOutput {
|
||||
fn from(value: MergeConflictKind) -> Self {
|
||||
match value {
|
||||
MergeConflictKind::DivergentInsert => Self::DivergentInsert,
|
||||
MergeConflictKind::DivergentUpdate => Self::DivergentUpdate,
|
||||
MergeConflictKind::DeleteVsUpdate => Self::DeleteVsUpdate,
|
||||
MergeConflictKind::OrphanEdge => Self::OrphanEdge,
|
||||
MergeConflictKind::UniqueViolation => Self::UniqueViolation,
|
||||
MergeConflictKind::CardinalityViolation => Self::CardinalityViolation,
|
||||
MergeConflictKind::ValueConstraintViolation => Self::ValueConstraintViolation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MergeConflictOutput {
|
||||
pub table_key: String,
|
||||
pub row_id: Option<String>,
|
||||
pub kind: MergeConflictKindOutput,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<&MergeConflict> for MergeConflictOutput {
|
||||
fn from(value: &MergeConflict) -> Self {
|
||||
Self {
|
||||
table_key: value.table_key.clone(),
|
||||
row_id: value.row_id.clone(),
|
||||
kind: value.kind.into(),
|
||||
message: value.message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReadTargetOutput {
|
||||
pub branch: Option<String>,
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReadOutput {
|
||||
pub query_name: String,
|
||||
pub target: ReadTargetOutput,
|
||||
pub row_count: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ChangeOutput {
|
||||
pub branch: String,
|
||||
pub query_name: String,
|
||||
pub affected_nodes: usize,
|
||||
pub affected_edges: usize,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IngestTableOutput {
|
||||
pub table_key: String,
|
||||
pub rows_loaded: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IngestOutput {
|
||||
pub uri: String,
|
||||
pub branch: String,
|
||||
/// Base branch a fork was requested from (the request's `from`), echoed
|
||||
/// even when the branch already existed. `null` when `from` was absent.
|
||||
pub base_branch: Option<String>,
|
||||
pub branch_created: bool,
|
||||
#[schema(value_type = LoadModeSchema)]
|
||||
pub mode: LoadMode,
|
||||
pub tables: Vec<IngestTableOutput>,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CommitOutput {
|
||||
pub graph_commit_id: String,
|
||||
pub manifest_branch: Option<String>,
|
||||
pub manifest_version: u64,
|
||||
pub parent_commit_id: Option<String>,
|
||||
pub merged_parent_commit_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
/// Commit creation time as Unix epoch microseconds.
|
||||
#[schema(example = 1714000000000000i64)]
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CommitListOutput {
|
||||
pub commits: Vec<CommitOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReadRequest {
|
||||
/// GQ query source. May declare one or more named queries; pick one with
|
||||
/// `query_name` if there is more than one.
|
||||
#[schema(
|
||||
example = "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}"
|
||||
)]
|
||||
pub query_source: String,
|
||||
/// Name of the query to run when `query_source` declares multiple. Optional
|
||||
/// when only one query is declared.
|
||||
pub query_name: Option<String>,
|
||||
/// JSON object whose keys match the query's declared parameters.
|
||||
pub params: Option<Value>,
|
||||
/// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Snapshot id to read from. Mutually exclusive with `branch`.
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
/// Inline read-query request for `POST /query`.
|
||||
///
|
||||
/// Friendlier-named alternative to [`ReadRequest`] for ad-hoc reads and
|
||||
/// AI-agent integration. Mutations are rejected with 400 — use `POST
|
||||
/// /mutate` (or its deprecated alias `POST /change`) for write queries.
|
||||
/// Field names are deliberately short (`query`, `name`) to match the GQ
|
||||
/// keyword and the CLI `-e` flag.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct 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.
|
||||
#[schema(example = "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}")]
|
||||
pub query: String,
|
||||
/// Name of the query to run when `query` declares multiple. Optional when
|
||||
/// only one query is declared.
|
||||
pub name: Option<String>,
|
||||
/// JSON object whose keys match the query's declared parameters.
|
||||
pub params: Option<Value>,
|
||||
/// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Snapshot id to read from. Mutually exclusive with `branch`.
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ChangeRequest {
|
||||
/// GQ mutation source containing `insert`, `update`, or `delete` statements.
|
||||
/// May declare multiple named mutations; pick one with `name`.
|
||||
///
|
||||
/// Accepts the legacy field name `query_source` as a deserialization alias.
|
||||
#[schema(
|
||||
example = "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}"
|
||||
)]
|
||||
#[serde(alias = "query_source")]
|
||||
pub query: String,
|
||||
/// Name of the mutation to run when `query` declares multiple.
|
||||
///
|
||||
/// Accepts the legacy field name `query_name` as a deserialization alias.
|
||||
#[serde(default, alias = "query_name")]
|
||||
pub name: Option<String>,
|
||||
/// JSON object whose keys match the mutation's declared parameters.
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
/// Target branch. Defaults to `main`.
|
||||
#[serde(default)]
|
||||
pub branch: Option<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,
|
||||
/// never the body; only the runtime inputs are supplied here.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct InvokeStoredQueryRequest {
|
||||
/// JSON object whose keys match the stored query's declared parameters.
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
/// Branch to run against. Defaults to `main`; for a stored mutation the
|
||||
/// write targets this branch.
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
/// Snapshot id to read from (read queries only — rejected for a stored
|
||||
/// mutation). Mutually exclusive with `branch`.
|
||||
#[serde(default)]
|
||||
pub snapshot: Option<String>,
|
||||
/// The kind the caller expects (RFC-011 Decision 3): `Some(false)` for
|
||||
/// `omnigraph query <name>`, `Some(true)` for `omnigraph mutate <name>`.
|
||||
/// When set and it disagrees with the stored query's actual kind, the
|
||||
/// server rejects the call (400) so the verb asserts the kind. `None`
|
||||
/// (the default) skips the check — preserving older clients and aliases.
|
||||
#[serde(default)]
|
||||
pub expect_mutation: Option<bool>,
|
||||
}
|
||||
|
||||
/// Response for `POST /queries/{name}`: the read envelope for a stored
|
||||
/// read, or the mutation envelope for a stored mutation. Serialized
|
||||
/// **untagged**, so the wire shape is exactly [`ReadOutput`] or
|
||||
/// [`ChangeOutput`] — classification follows the stored query, not a
|
||||
/// wrapper field.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum InvokeStoredQueryResponse {
|
||||
Read(ReadOutput),
|
||||
Change(ChangeOutput),
|
||||
}
|
||||
|
||||
/// The kind of a stored-query parameter, decomposed so a client (e.g. an
|
||||
/// MCP server) can build a typed input schema with a closed `match` and
|
||||
/// never re-parse omnigraph's type spelling. `bigint`/`date`/`datetime`/
|
||||
/// `blob` are carried as JSON strings on the wire: a 64-bit integer past
|
||||
/// 2^53 loses precision as a JSON number, and Date/DateTime are ISO
|
||||
/// strings, Blob a blob-URI string.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ParamKind {
|
||||
String,
|
||||
Bool,
|
||||
Int,
|
||||
#[serde(rename = "bigint")]
|
||||
BigInt,
|
||||
Float,
|
||||
Date,
|
||||
#[serde(rename = "datetime")]
|
||||
DateTime,
|
||||
Blob,
|
||||
Vector,
|
||||
List,
|
||||
}
|
||||
|
||||
/// One declared parameter of a stored query, projected for the catalog.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ParamDescriptor {
|
||||
pub name: String,
|
||||
pub kind: ParamKind,
|
||||
/// Element kind when `kind == list` (always a scalar — the grammar
|
||||
/// forbids lists of vectors or nested lists).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub item_kind: Option<ParamKind>,
|
||||
/// Dimension when `kind == vector`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub vector_dim: Option<u32>,
|
||||
/// `false` → the caller must supply it; `true` → optional.
|
||||
pub nullable: bool,
|
||||
}
|
||||
|
||||
/// One entry in the stored-query catalog (`GET /queries`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct QueryCatalogEntry {
|
||||
/// Registry key / invoke path segment (`POST /queries/{name}`).
|
||||
pub name: String,
|
||||
/// MCP tool id (the `tool_name` override, else `name`).
|
||||
pub tool_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instruction: Option<String>,
|
||||
/// `true` for a stored mutation → an MCP read-only hint of `false`.
|
||||
pub mutation: bool,
|
||||
pub params: Vec<ParamDescriptor>,
|
||||
}
|
||||
|
||||
/// Response for `GET /queries`: every stored query in a graph's
|
||||
/// registry, each with typed parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct QueriesCatalogOutput {
|
||||
pub queries: Vec<QueryCatalogEntry>,
|
||||
}
|
||||
|
||||
/// Total map from a resolved scalar to its catalog kind. Exhaustive on
|
||||
/// purpose: a new `ScalarType` is a compile error here until catalogued.
|
||||
fn scalar_kind(scalar: ScalarType) -> ParamKind {
|
||||
match scalar {
|
||||
ScalarType::String => ParamKind::String,
|
||||
ScalarType::Bool => ParamKind::Bool,
|
||||
ScalarType::I32 | ScalarType::U32 => ParamKind::Int,
|
||||
ScalarType::I64 | ScalarType::U64 => ParamKind::BigInt,
|
||||
ScalarType::F32 | ScalarType::F64 => ParamKind::Float,
|
||||
ScalarType::Date => ParamKind::Date,
|
||||
ScalarType::DateTime => ParamKind::DateTime,
|
||||
ScalarType::Blob => ParamKind::Blob,
|
||||
ScalarType::Vector(_) => ParamKind::Vector,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn param_descriptor(param: &Param) -> ParamDescriptor {
|
||||
match PropType::from_param_type_name(¶m.type_name, param.nullable) {
|
||||
Some(pt) if pt.list => ParamDescriptor {
|
||||
name: param.name.clone(),
|
||||
kind: ParamKind::List,
|
||||
item_kind: Some(scalar_kind(pt.scalar)),
|
||||
vector_dim: None,
|
||||
nullable: param.nullable,
|
||||
},
|
||||
Some(pt) => {
|
||||
let (kind, vector_dim) = match pt.scalar {
|
||||
ScalarType::Vector(dim) => (ParamKind::Vector, Some(dim)),
|
||||
other => (scalar_kind(other), None),
|
||||
};
|
||||
ParamDescriptor {
|
||||
name: param.name.clone(),
|
||||
kind,
|
||||
item_kind: None,
|
||||
vector_dim,
|
||||
nullable: param.nullable,
|
||||
}
|
||||
}
|
||||
// Unreachable for a parsed query (every declared param type is
|
||||
// grammatical); fall back to an opaque string so the field is still
|
||||
// usable rather than dropped.
|
||||
None => ParamDescriptor {
|
||||
name: param.name.clone(),
|
||||
kind: ParamKind::String,
|
||||
item_kind: None,
|
||||
vector_dim: None,
|
||||
nullable: param.nullable,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SchemaApplyRequest {
|
||||
/// Project schema in `.pg` source form. The diff against the current
|
||||
/// schema produces the migration steps that will be applied.
|
||||
#[schema(
|
||||
example = "node Person {\n name: String @key\n age: I32?\n}\n\nedge Knows: Person -> Person"
|
||||
)]
|
||||
pub schema_source: String,
|
||||
/// When true, promote every `DropMode::Soft` step in the plan to
|
||||
/// `DropMode::Hard`, making the prior column data unreachable
|
||||
/// after the apply. Matches the CLI's `--allow-data-loss` flag.
|
||||
/// Defaults to `false` (drops remain reversible via time travel).
|
||||
#[serde(default)]
|
||||
pub allow_data_loss: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SchemaApplyOutput {
|
||||
pub uri: String,
|
||||
pub supported: bool,
|
||||
pub applied: bool,
|
||||
pub step_count: usize,
|
||||
pub manifest_version: u64,
|
||||
#[schema(value_type = Vec<Value>)]
|
||||
pub steps: Vec<SchemaMigrationStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SchemaOutput {
|
||||
pub schema_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IngestRequest {
|
||||
/// Target branch. Defaults to `main`. Without `from`, the branch must
|
||||
/// already exist — a missing branch is a 404, never an implicit fork.
|
||||
pub branch: Option<String>,
|
||||
/// Parent branch used to create `branch` if it does not exist. Branch
|
||||
/// creation is opt-in by presence of this field; omit it to require an
|
||||
/// existing branch.
|
||||
pub from: Option<String>,
|
||||
/// How existing rows are handled. Defaults to `merge`.
|
||||
#[schema(value_type = Option<LoadModeSchema>)]
|
||||
pub mode: Option<LoadMode>,
|
||||
/// NDJSON payload: one record per line, each shaped
|
||||
/// `{"type": "<TypeName>", "data": {...}}`.
|
||||
#[schema(
|
||||
example = "{\"type\": \"Person\", \"data\": {\"name\": \"Alice\", \"age\": 30}}\n{\"type\": \"Person\", \"data\": {\"name\": \"Bob\", \"age\": 25}}"
|
||||
)]
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ExportRequest {
|
||||
/// Branch to export. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Restrict the export to these node/edge type names. Empty exports all types.
|
||||
#[serde(default)]
|
||||
pub type_names: Vec<String>,
|
||||
/// Restrict the export to these table keys. Empty exports all tables.
|
||||
#[serde(default)]
|
||||
pub table_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||
pub struct SnapshotQuery {
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||
pub struct CommitListQuery {
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthOutput {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
/// 405 Method Not Allowed — the route exists but the active server
|
||||
/// mode doesn't serve this method (e.g. `GET /graphs` in single-graph
|
||||
/// mode). Distinct from 404 so clients can tell "wrong context" from
|
||||
/// "no such resource."
|
||||
MethodNotAllowed,
|
||||
Conflict,
|
||||
/// 429 Too Many Requests — per-actor admission cap exceeded.
|
||||
/// Clients should respect the `Retry-After` header.
|
||||
TooManyRequests,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ManifestConflictOutput {
|
||||
pub table_key: String,
|
||||
pub expected: u64,
|
||||
pub actual: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ErrorOutput {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<ErrorCode>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub merge_conflicts: Vec<MergeConflictOutput>,
|
||||
/// Set when the conflict is a publisher CAS rejection
|
||||
/// (`ManifestConflictDetails::ExpectedVersionMismatch`). The caller's
|
||||
/// pre-write view of `table_key` was at version `expected` but the
|
||||
/// manifest is now at `actual`. Refresh and retry.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_conflict: Option<ManifestConflictOutput>,
|
||||
}
|
||||
|
||||
pub fn snapshot_payload(branch: &str, snapshot: &Snapshot) -> SnapshotOutput {
|
||||
let mut entries: Vec<_> = snapshot.entries().cloned().collect();
|
||||
entries.sort_by(|a, b| a.table_key.cmp(&b.table_key));
|
||||
let tables = entries
|
||||
.iter()
|
||||
.map(|entry| SnapshotTableOutput {
|
||||
table_key: entry.table_key.clone(),
|
||||
table_path: entry.table_path.clone(),
|
||||
table_version: entry.table_version,
|
||||
table_branch: entry.table_branch.clone(),
|
||||
row_count: entry.row_count,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
SnapshotOutput {
|
||||
branch: branch.to_string(),
|
||||
manifest_version: snapshot.version(),
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema_apply_output(uri: &str, result: SchemaApplyResult) -> SchemaApplyOutput {
|
||||
SchemaApplyOutput {
|
||||
uri: uri.to_string(),
|
||||
supported: result.supported,
|
||||
applied: result.applied,
|
||||
step_count: result.steps.len(),
|
||||
manifest_version: result.manifest_version,
|
||||
steps: result.steps,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_output(commit: &GraphCommit) -> CommitOutput {
|
||||
CommitOutput {
|
||||
graph_commit_id: commit.graph_commit_id.clone(),
|
||||
manifest_branch: commit.manifest_branch.clone(),
|
||||
manifest_version: commit.manifest_version,
|
||||
parent_commit_id: commit.parent_commit_id.clone(),
|
||||
merged_parent_commit_id: commit.merged_parent_commit_id.clone(),
|
||||
actor_id: commit.actor_id.clone(),
|
||||
created_at: commit.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_output(query_name: String, target: &ReadTarget, result: QueryResult) -> ReadOutput {
|
||||
let columns = result
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.name().clone())
|
||||
.collect();
|
||||
ReadOutput {
|
||||
query_name,
|
||||
target: read_target_output(target),
|
||||
row_count: result.num_rows(),
|
||||
columns,
|
||||
rows: result.to_rust_json(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ingest_output(
|
||||
uri: &str,
|
||||
result: &LoadResult,
|
||||
mode: LoadMode,
|
||||
actor_id: Option<String>,
|
||||
) -> IngestOutput {
|
||||
IngestOutput {
|
||||
uri: uri.to_string(),
|
||||
branch: result.branch.clone(),
|
||||
base_branch: result.base_branch.clone(),
|
||||
branch_created: result.branch_created,
|
||||
mode,
|
||||
tables: result
|
||||
.to_ingest_tables()
|
||||
.into_iter()
|
||||
.map(|table| IngestTableOutput {
|
||||
table_key: table.table_key,
|
||||
rows_loaded: table.rows_loaded,
|
||||
})
|
||||
.collect(),
|
||||
actor_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_target_output(target: &ReadTarget) -> ReadTargetOutput {
|
||||
match target {
|
||||
ReadTarget::Branch(branch) => ReadTargetOutput {
|
||||
branch: Some(branch.clone()),
|
||||
snapshot: None,
|
||||
},
|
||||
ReadTarget::Snapshot(snapshot) => ReadTargetOutput {
|
||||
branch: None,
|
||||
snapshot: Some(snapshot.as_str().to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MR-668 — management endpoint shapes ──────────────────────────────────
|
||||
|
||||
/// One entry in the response from `GET /graphs`. Cluster operators
|
||||
/// consume this list to discover which graphs the server is currently
|
||||
/// serving. The shape is intentionally minimal — `graph_id` and `uri`
|
||||
/// are the only fields a routing client needs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct GraphInfo {
|
||||
pub graph_id: String,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
/// Response from `GET /graphs`. Lists every graph registered with the
|
||||
/// server in alphabetical order by `graph_id` (sorted server-side so
|
||||
/// clients get deterministic output across requests).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct GraphListResponse {
|
||||
pub graphs: Vec<GraphInfo>,
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "omnigraph-cli"
|
||||
version = "0.7.2"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "CLI for the Omnigraph graph database."
|
||||
license = "MIT"
|
||||
|
|
@ -13,12 +13,10 @@ name = "omnigraph"
|
|||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.7.2" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.2" }
|
||||
omnigraph-api-types = { path = "../omnigraph-api-types", version = "0.7.2" }
|
||||
omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.7.2" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.7.2" }
|
||||
omnigraph-server = { path = "../omnigraph-server", version = "0.7.2" }
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.6.0" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.6.0" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.6.0" }
|
||||
omnigraph-server = { path = "../omnigraph-server", version = "0.6.0" }
|
||||
clap = { workspace = true }
|
||||
color-eyre = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,678 +0,0 @@
|
|||
//! The clap surface: every command, subcommand, and argument struct
|
||||
//! (moved verbatim from main.rs in the modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) const DEFAULT_BEARER_TOKEN_ENV: &str = "OMNIGRAPH_BEARER_TOKEN";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "omnigraph")]
|
||||
#[command(about = "Omnigraph graph database CLI")]
|
||||
#[command(version = env!("CARGO_PKG_VERSION"), disable_version_flag = true)]
|
||||
// Subcommands render in declaration order (clap can't print labeled headings
|
||||
// between groups), so this legend names the capability each command needs —
|
||||
// the user-facing vocabulary (RFC-011). `Plane` stays the internal classifier.
|
||||
#[command(after_help = "\
|
||||
COMMANDS BY CAPABILITY:\n \
|
||||
any — run against a graph, served (--server / --profile) or embedded (--store / a \
|
||||
URI): query, mutate, load, branch, snapshot, export, commit, schema show/apply.\n \
|
||||
served — require a server: graphs.\n \
|
||||
direct — direct storage access; reject --server (init, optimize, repair, cleanup, \
|
||||
schema plan, lint).\n \
|
||||
control — manage or inspect a cluster (cluster via --config; policy & queries via \
|
||||
--cluster).\n \
|
||||
local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\
|
||||
See the 'Command capabilities' section of the CLI reference for which flags apply where.")]
|
||||
pub(crate) struct Cli {
|
||||
/// Actor id for direct-engine writes; overrides `cli.actor`. No effect on
|
||||
/// remote writes (the server resolves the actor from the bearer token).
|
||||
/// With a policy configured but no actor set, the write is denied — see
|
||||
/// docs/user/operations/policy.md.
|
||||
#[arg(long = "as", global = true, value_name = "ACTOR")]
|
||||
pub(crate) as_actor: Option<String>,
|
||||
|
||||
/// Address a server by name (resolves to its `url` from `servers:` in
|
||||
/// ~/.omnigraph/config.yaml) or by a literal `http(s)://` URL. Exclusive
|
||||
/// with a positional URI.
|
||||
#[arg(long, global = true, value_name = "NAME|URL")]
|
||||
pub(crate) server: Option<String>,
|
||||
|
||||
/// Select a graph within a multi-graph scope: on a `--server` it appends
|
||||
/// `/graphs/<id>` to the server url; on a `--cluster` it picks which
|
||||
/// cluster graph to maintain. Rejected on a single-graph address (a
|
||||
/// positional URI / `--store`).
|
||||
#[arg(long, global = true, value_name = "GRAPH_ID")]
|
||||
pub(crate) graph: Option<String>,
|
||||
|
||||
/// Select a named scope bundle (RFC-011) from `profiles:` in
|
||||
/// ~/.omnigraph/config.yaml: fills in this command's omitted addressing
|
||||
/// (server/cluster/store + default graph). Falls back to
|
||||
/// $OMNIGRAPH_PROFILE. Config data, not state — every command resolves
|
||||
/// scope fresh.
|
||||
#[arg(long, global = true, value_name = "NAME")]
|
||||
pub(crate) profile: Option<String>,
|
||||
|
||||
/// Address a single graph's storage directly (RFC-011): a `file://` /
|
||||
/// `s3://` store URI. Explicit, ad-hoc direct access — bypasses any
|
||||
/// server. Exclusive with a positional URI / `--server`.
|
||||
#[arg(long, global = true, value_name = "URI")]
|
||||
pub(crate) store: Option<String>,
|
||||
|
||||
/// Address a cluster-managed graph's storage for maintenance (RFC-011):
|
||||
/// a cluster directory or storage-root URI — named via `clusters:` in
|
||||
/// ~/.omnigraph/config.yaml, or a literal `file://`/`s3://` root. Pair
|
||||
/// with `--graph <id>` to select the graph. Used by optimize / repair /
|
||||
/// cleanup; exclusive with a positional URI / `--store` / `--server`.
|
||||
#[arg(long, global = true, value_name = "DIR|URI")]
|
||||
pub(crate) cluster: Option<String>,
|
||||
|
||||
/// Skip the confirmation prompt for a destructive write (`cleanup`,
|
||||
/// overwrite `load`, `branch delete`) against a non-local scope (RFC-011
|
||||
/// Decision 9). Without it, a non-local destructive write prompts on a TTY
|
||||
/// and refuses (errors) when there is no TTY or `--json` is set.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) yes: bool,
|
||||
|
||||
/// Suppress the one-line resolved-write-target diagnostic that write
|
||||
/// commands echo to stderr (RFC-011 Decision 9).
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) quiet: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
// ── Data plane ── run against a graph (embedded or via --server).
|
||||
/// Execute a read query against a branch or snapshot.
|
||||
///
|
||||
/// Canonical read endpoint. The previous name `omnigraph read` is
|
||||
/// kept as a visible alias and prints a one-line deprecation warning
|
||||
/// when used. Pairs with `omnigraph mutate` on the write side.
|
||||
#[command(visible_alias = "read")]
|
||||
Query {
|
||||
/// Query name. With no `--query`/`-e`, the stored query to invoke from
|
||||
/// the catalog (served — addressed via --server/--profile). With
|
||||
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
|
||||
name: Option<String>,
|
||||
/// Ad-hoc query file (a `.gq` you're authoring / break-glass).
|
||||
#[arg(long, conflicts_with = "query_string")]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
|
||||
query_string: Option<String>,
|
||||
#[command(flatten)]
|
||||
params: ParamsArgs,
|
||||
#[arg(long, conflicts_with = "snapshot")]
|
||||
branch: Option<String>,
|
||||
#[arg(long, conflicts_with = "branch")]
|
||||
snapshot: Option<String>,
|
||||
#[arg(long, conflicts_with = "json")]
|
||||
format: Option<ReadOutputFormat>,
|
||||
#[arg(long, conflicts_with = "format")]
|
||||
json: bool,
|
||||
},
|
||||
/// Execute a graph mutation query against a branch.
|
||||
///
|
||||
/// Canonical mutation endpoint. The previous name `omnigraph change`
|
||||
/// is kept as a visible alias and prints a one-line deprecation
|
||||
/// warning when used. Pairs with `omnigraph query` on the read side.
|
||||
#[command(visible_alias = "change")]
|
||||
Mutate {
|
||||
/// Query name. With no `--query`/`-e`, the stored mutation to invoke
|
||||
/// from the catalog (served — addressed via --server/--profile). With
|
||||
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
|
||||
name: Option<String>,
|
||||
/// Ad-hoc mutation file (a `.gq` you're authoring / break-glass).
|
||||
#[arg(long, conflicts_with = "query_string")]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
|
||||
query_string: Option<String>,
|
||||
#[command(flatten)]
|
||||
params: ParamsArgs,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Invoke an operator alias (RFC-011 Decision 4).
|
||||
///
|
||||
/// An alias is a personal binding under `aliases:` in
|
||||
/// ~/.omnigraph/config.yaml — name → (server, graph, stored-query name,
|
||||
/// default params). `omnigraph alias <name> [args]` invokes the bound
|
||||
/// stored query on its server. Living in its own namespace, an alias can
|
||||
/// never shadow or be shadowed by a built-in verb. Replaces the removed
|
||||
/// `--alias` flag on `query`/`mutate`.
|
||||
Alias {
|
||||
/// Alias name (a key under `aliases:` in ~/.omnigraph/config.yaml).
|
||||
name: String,
|
||||
/// Positional args bound to the alias's declared `args` params, in order.
|
||||
args: Vec<String>,
|
||||
#[command(flatten)]
|
||||
params: ParamsArgs,
|
||||
#[arg(long, conflicts_with = "json")]
|
||||
format: Option<ReadOutputFormat>,
|
||||
#[arg(long, conflicts_with = "format")]
|
||||
json: bool,
|
||||
},
|
||||
/// Load data into a graph (local or remote)
|
||||
Load {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
data: PathBuf,
|
||||
/// Target branch (defaults to main). Without --from it must exist.
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
/// Base branch to fork --branch from when it doesn't exist yet.
|
||||
/// Without this flag a missing branch is an error, never a fork.
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
/// How existing rows are handled: overwrite | append | merge.
|
||||
/// Required — overwrite is destructive, so there is no default.
|
||||
#[arg(long)]
|
||||
mode: CliLoadMode,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Deprecated alias of `load --from <base>` (defaults: --mode merge, --from main)
|
||||
#[command(hide = true)]
|
||||
Ingest {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
data: PathBuf,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
#[arg(long, default_value = "merge")]
|
||||
mode: CliLoadMode,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Branch operations
|
||||
Branch {
|
||||
#[command(subcommand)]
|
||||
command: BranchCommand,
|
||||
},
|
||||
/// Show graph snapshot
|
||||
Snapshot {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Export a full graph snapshot as JSONL
|
||||
Export {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long, hide = true)]
|
||||
jsonl: bool,
|
||||
#[arg(long = "type")]
|
||||
type_names: Vec<String>,
|
||||
#[arg(long = "table")]
|
||||
table_keys: Vec<String>,
|
||||
},
|
||||
/// Commit history operations
|
||||
Commit {
|
||||
#[command(subcommand)]
|
||||
command: CommitCommand,
|
||||
},
|
||||
/// Schema planning operations
|
||||
Schema {
|
||||
#[command(subcommand)]
|
||||
command: SchemaCommand,
|
||||
},
|
||||
/// Manage graphs on a multi-graph server (MR-668)
|
||||
Graphs {
|
||||
#[command(subcommand)]
|
||||
command: GraphsCommand,
|
||||
},
|
||||
|
||||
// ── Storage / local graph ops ── direct storage or local files; reject --server.
|
||||
/// Initialize a new graph from a schema
|
||||
Init {
|
||||
#[arg(long)]
|
||||
schema: PathBuf,
|
||||
/// Graph URI (local path or s3://)
|
||||
uri: String,
|
||||
/// Overwrite existing schema artifacts at the URI. Without
|
||||
/// this flag, init refuses to touch a URI that already holds
|
||||
/// `_schema.pg`, `_schema.ir.json`, or `__schema_state.json`
|
||||
/// — closes the re-init footgun (MR-668 follow-up). With the
|
||||
/// flag, the operator opts in to destructive semantics.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Compact small Lance fragments in every table of the graph
|
||||
Optimize {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Classify and explicitly repair manifest/head drift
|
||||
Repair {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
/// Publish verified maintenance drift. Without this flag, repair only
|
||||
/// previews what it would do.
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
/// Also publish suspicious or unverifiable drift. Requires
|
||||
/// `--confirm`; use only after operator review.
|
||||
#[arg(long, requires = "confirm")]
|
||||
force: bool,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Remove old Lance versions from every table of the graph (destructive)
|
||||
Cleanup {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
/// Number of recent versions to keep per table. Either `--keep` or
|
||||
/// `--older-than` (or both) must be set.
|
||||
#[arg(long)]
|
||||
keep: Option<u32>,
|
||||
/// Only remove versions older than this duration. Accepts Go-style
|
||||
/// durations: `7d`, `24h`, `90m`. At least one of --keep / --older-than.
|
||||
#[arg(long)]
|
||||
older_than: Option<String>,
|
||||
/// Required to actually run; without it, prints what would be removed
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Validate queries against a schema (offline) or repo (repo-backed).
|
||||
///
|
||||
/// Canonical name is `lint` (matches the `omnigraph_compiler::lint`
|
||||
/// module and the `OG-XXX-NNN` lint-code vocabulary). Replaces the
|
||||
/// deprecated `omnigraph query lint` / `omnigraph query check` /
|
||||
/// `omnigraph check` invocations — each is kept as an argv-level
|
||||
/// shim that prints a one-line stderr warning and rewrites to
|
||||
/// `omnigraph lint`. Aliases are deliberately *not* exposed via
|
||||
/// clap's `visible_alias` because that would advertise two
|
||||
/// equivalent canonical names, which agents emit interchangeably
|
||||
/// (see MR-981).
|
||||
Lint {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
query: PathBuf,
|
||||
#[arg(long)]
|
||||
schema: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Operate on the server-side stored-query registry (`queries:`).
|
||||
Queries {
|
||||
#[command(subcommand)]
|
||||
command: QueriesCommand,
|
||||
},
|
||||
|
||||
// ── Control plane ── manage a cluster directory (--config <dir>).
|
||||
/// Validate and plan read-only cluster configuration.
|
||||
Cluster {
|
||||
#[command(subcommand)]
|
||||
command: ClusterCommand,
|
||||
},
|
||||
|
||||
/// Policy administration and diagnostics against a cluster's applied bundles
|
||||
Policy {
|
||||
#[command(subcommand)]
|
||||
command: PolicyCommand,
|
||||
},
|
||||
/// Generate, clean, or refresh explicit seed embeddings
|
||||
Embed(EmbedArgs),
|
||||
/// Store a bearer token for a named server (0600 credentials file). Token
|
||||
/// via --token or piped on stdin; see the CLI reference for token resolution.
|
||||
Login {
|
||||
/// Server name (keys the credential; declare its url under
|
||||
/// `servers:` in ~/.omnigraph/config.yaml)
|
||||
name: String,
|
||||
/// The token. Prefer piping via stdin over this flag (shell
|
||||
/// history).
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Remove a named server's stored credential. Idempotent.
|
||||
Logout {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Inspect the scope profiles in ~/.omnigraph/config.yaml (read-only).
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
command: ProfileCommand,
|
||||
},
|
||||
/// Print the CLI version
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ProfileCommand {
|
||||
/// List the profiles defined in ~/.omnigraph/config.yaml.
|
||||
List {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show a profile's resolved scope. With no name, shows the active
|
||||
/// (`$OMNIGRAPH_PROFILE`) profile, else the flat operator defaults.
|
||||
Show {
|
||||
/// Profile name (optional).
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ClusterCommand {
|
||||
/// Validate cluster.yaml and referenced schemas, queries, and policy files.
|
||||
Validate {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Produce a read-only plan by diffing cluster.yaml against __cluster/state.json.
|
||||
Plan {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Converge the cluster to its config: create graphs, apply schema updates
|
||||
/// (soft drops), write stored-query/policy catalog resources, and execute
|
||||
/// approved graph deletes, in one ordered run. Serving picks up the applied
|
||||
/// revision after an `omnigraph-server --cluster` restart.
|
||||
Apply {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Record a digest-bound approval for a gated (irreversible) change,
|
||||
/// e.g. a graph delete. Requires the global --as actor.
|
||||
Approve {
|
||||
/// Typed resource address of the gated change (e.g. graph.scratch).
|
||||
resource: String,
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Read the local JSON state ledger without scanning live graph resources.
|
||||
Status {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Refresh existing local JSON state from declared graph observations.
|
||||
Refresh {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Import initial local JSON state from declared graph observations.
|
||||
Import {
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Remove a held local JSON state lock after operator confirmation.
|
||||
ForceUnlock {
|
||||
/// Exact lock id from cluster status or a state_lock_held diagnostic.
|
||||
lock_id: String,
|
||||
/// Cluster config directory containing cluster.yaml.
|
||||
#[arg(long, default_value = ".")]
|
||||
config: PathBuf,
|
||||
/// Emit JSON instead of human text.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Operations on the graph registry of a multi-graph server (MR-668).
|
||||
///
|
||||
/// All operations target a remote multi-graph server URL (http:// or
|
||||
/// https://). Local-URI invocations return a clear error. To add or
|
||||
/// remove graphs, operators edit `omnigraph.yaml` directly and restart
|
||||
/// the server — runtime mutation is not exposed in v0.6.0.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum GraphsCommand {
|
||||
/// List every graph registered with the multi-graph server.
|
||||
List {
|
||||
/// Remote server URL (e.g. `https://server.example.com`).
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum BranchCommand {
|
||||
/// Create a new branch
|
||||
Create {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// List branches
|
||||
List {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Delete a branch
|
||||
Delete {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Merge a source branch into a target branch
|
||||
Merge {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
source: String,
|
||||
#[arg(long)]
|
||||
into: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum SchemaCommand {
|
||||
/// Plan a schema migration against the accepted persisted schema
|
||||
Plan {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
schema: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Show the plan as it would execute with `--allow-data-loss`.
|
||||
/// Promotes every `DropMode::Soft` step to `DropMode::Hard`
|
||||
/// so the plan output reflects the destructive intent.
|
||||
#[arg(long, default_value_t = false)]
|
||||
allow_data_loss: bool,
|
||||
},
|
||||
/// Apply a supported schema migration
|
||||
Apply {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
schema: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Allow destructive (data-loss) schema changes.
|
||||
///
|
||||
/// Without this flag, drops are "soft": the column or table
|
||||
/// is removed from the current manifest version but prior
|
||||
/// versions are retained, so `snapshot_at_version(pre_drop)`
|
||||
/// can still read the dropped data until `omnigraph cleanup`
|
||||
/// runs. With this flag, drops are "hard": `cleanup_old_versions`
|
||||
/// runs on the affected datasets immediately after the apply,
|
||||
/// making the prior data unreachable.
|
||||
#[arg(long, default_value_t = false)]
|
||||
allow_data_loss: bool,
|
||||
},
|
||||
/// Show the current accepted schema source
|
||||
#[command(alias = "get")]
|
||||
Show {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
|
||||
pub(crate) enum CommitCommand {
|
||||
/// List graph commits
|
||||
List {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show a graph commit
|
||||
Show {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
commit_id: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum PolicyCommand {
|
||||
/// Compile and validate the Cedar policy bundle(s) applied in a cluster.
|
||||
///
|
||||
/// Sources the bundle(s) from the cluster's applied policies
|
||||
/// (`--cluster <dir>`); pass the global `--graph <id>` to pick one
|
||||
/// graph's bundle when several apply.
|
||||
Validate {},
|
||||
/// Run declarative policy tests against a cluster's applied bundle.
|
||||
///
|
||||
/// The cluster model has no per-bundle tests file, so the cases are
|
||||
/// supplied explicitly with `--tests <file>` and checked against the
|
||||
/// bundle selected by `--cluster` (+ optional `--graph`).
|
||||
Test {
|
||||
/// Path to a policy.tests.yaml file.
|
||||
#[arg(long)]
|
||||
tests: PathBuf,
|
||||
},
|
||||
/// Explain one policy decision against a cluster's applied bundle.
|
||||
Explain {
|
||||
#[arg(long)]
|
||||
actor: String,
|
||||
#[arg(long)]
|
||||
action: PolicyAction,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long = "target-branch")]
|
||||
target_branch: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum QueriesCommand {
|
||||
/// Type-check a cluster's stored-query registry against its schemas.
|
||||
///
|
||||
/// Distinct from `omnigraph lint` (which lints one `.gq` file): this
|
||||
/// validates the whole `queries:` registry of a cluster (`--cluster
|
||||
/// <dir>`, optional `--graph <id>`) by reading each graph's applied
|
||||
/// schema and confirming every stored query still type-checks. Exits
|
||||
/// non-zero on any breakage.
|
||||
Validate {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// List a cluster's registered stored queries (name, params).
|
||||
List {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub(crate) struct ParamsArgs {
|
||||
#[arg(long, conflicts_with = "params_file")]
|
||||
pub(crate) params: Option<String>,
|
||||
#[arg(long, conflicts_with = "params")]
|
||||
pub(crate) params_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum CliLoadMode {
|
||||
Overwrite,
|
||||
Append,
|
||||
Merge,
|
||||
}
|
||||
|
||||
impl From<CliLoadMode> for LoadMode {
|
||||
fn from(value: CliLoadMode) -> Self {
|
||||
match value {
|
||||
CliLoadMode::Overwrite => LoadMode::Overwrite,
|
||||
CliLoadMode::Append => LoadMode::Append,
|
||||
CliLoadMode::Merge => LoadMode::Merge,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CliLoadMode {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CliLoadMode::Overwrite => "overwrite",
|
||||
CliLoadMode::Append => "append",
|
||||
CliLoadMode::Merge => "merge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,821 +0,0 @@
|
|||
//! `GraphClient` — the one place the embedded-vs-remote split lives
|
||||
//! (RFC-009 Phase 3). A CLI command body calls a verb method; the
|
||||
//! enum routes to the engine (local URI) or HTTP (remote URI). The
|
||||
//! 15 per-command `if graph.is_remote { … } else { … }` forks collapse
|
||||
//! into two arms here.
|
||||
//!
|
||||
//! Phase 3a put the factory + the uniform read verbs in place. Phase 3b
|
||||
//! adds the data-plane writes (`load`/`ingest`/`mutate`/`branch_*`/
|
||||
//! `apply_schema`) and `query`. The wrinkle 3a deferred: writes open the
|
||||
//! local engine WITH policy (`open_local_db_with_policy`) and carry a
|
||||
//! resolved actor, while reads/`query` open WITHOUT policy. So the
|
||||
//! `Embedded` variant grows an optional policy context (`graph`/`actor`)
|
||||
//! and a second factory (`resolve_with_policy`) fills it; `resolve()`
|
||||
//! leaves it empty. The open path picks itself from whether `graph` is
|
||||
//! set, preserving today's two behaviors exactly. Export + graphs-list
|
||||
//! land in 3c. Behavior is unchanged per verb — the Phase-1 parity matrix
|
||||
//! is the referee and stays textually unchanged.
|
||||
//!
|
||||
//! Enum, not a trait (RFC sketch said "trait"): only two variants ever,
|
||||
//! and inherent async methods sidestep `async_trait` boxing plus the
|
||||
//! `apply_schema` catalog-validator closure that is not object-safe.
|
||||
//! Same one-body-two-impls collapse, less ceremony.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::bail;
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
use omnigraph_api_types::{
|
||||
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
|
||||
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
|
||||
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
|
||||
InvokeStoredQueryRequest, ReadOutput,
|
||||
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output,
|
||||
ingest_output, read_output, schema_apply_output, snapshot_payload,
|
||||
};
|
||||
use omnigraph_compiler::catalog::Catalog;
|
||||
use reqwest::Method;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cli::CliLoadMode;
|
||||
use crate::helpers::{
|
||||
apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
|
||||
legacy_change_request_body, query_params_from_json,
|
||||
remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token,
|
||||
resolve_server_flag, select_named_query,
|
||||
};
|
||||
use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables};
|
||||
|
||||
pub(crate) enum GraphClient {
|
||||
/// Local engine at `uri`. Reads (`resolve()`) leave `actor` empty;
|
||||
/// writes (`resolve_with_policy()`) attribute the resolved actor.
|
||||
/// Direct-store access carries no Cedar policy (RFC-011: policy lives
|
||||
/// in the cluster/server, not in per-operator addressing).
|
||||
Embedded {
|
||||
uri: String,
|
||||
actor: Option<String>,
|
||||
},
|
||||
/// Remote HTTP server. The actor is resolved server-side from the
|
||||
/// token; the client never sets identity.
|
||||
Remote {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
token: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// RFC-011 Decision 7: a server scope that selects no graph (no `--graph`, no
|
||||
/// `default_graph`) must not silently fall through to the bare server URL when
|
||||
/// the server is multi-graph. Best-effort probe `GET /graphs`: a populated list
|
||||
/// forces `--graph` (listing the candidates); a single-graph/flat server (405),
|
||||
/// a policy-gated `/graphs`, or an unreachable server all proceed — the bare URL
|
||||
/// is then correct, or the real request surfaces the failure. Only fires on the
|
||||
/// no-graph path, so a `--graph`/`default_graph` happy path does no extra I/O.
|
||||
async fn require_graph_for_multi_graph_server(
|
||||
scope: &crate::scope::ResolvedScope,
|
||||
) -> Result<()> {
|
||||
let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(base) = resolve_server_flag(Some(server), None)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let token = resolve_remote_bearer_token(Some(&base))?;
|
||||
let probe = GraphClient::Remote {
|
||||
http: build_http_client()?,
|
||||
base_url: base,
|
||||
token,
|
||||
};
|
||||
if let Ok(resp) = probe.list_graphs().await {
|
||||
if !resp.graphs.is_empty() {
|
||||
let ids: Vec<&str> = resp.graphs.iter().map(|g| g.graph_id.as_str()).collect();
|
||||
bail!(
|
||||
"server scope '{server}' has {} {}: [{}]; pass --graph <id> to select one \
|
||||
(or set `default_graph` in your operator config)",
|
||||
ids.len(),
|
||||
if ids.len() == 1 { "graph" } else { "graphs" },
|
||||
ids.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A remote graph must be addressed with `--server` (RFC-011): a positional or
|
||||
/// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL
|
||||
/// produced by a server scope (`via_server`) is fine.
|
||||
fn reject_positional_remote(via_server: bool, uri: &str) -> Result<()> {
|
||||
if !via_server && is_remote_uri(uri) {
|
||||
bail!(
|
||||
"a remote graph must be addressed with `--server <url>` — a positional \
|
||||
(or `--uri`) http(s):// URL no longer dispatches to a server"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl GraphClient {
|
||||
/// Resolve the addressing (positional URI / `--target` / `--server`)
|
||||
/// and credential once, then pick the variant by URI scheme — the
|
||||
/// single branch point that replaces every per-command `is_remote`
|
||||
/// fork. Mirrors the read verbs' current preamble (`resolve_uri`
|
||||
/// path, not the policy-bearing `resolve_cli_graph`). Used by reads
|
||||
/// and `query` (which opens without policy, like the reads).
|
||||
pub(crate) async fn resolve(
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
uri: Option<String>,
|
||||
profile: Option<&str>,
|
||||
store: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// RFC-011: a scope (profile / --store / operator defaults) may stand in
|
||||
// for omitted addressing. The explicit branch passes server/graph/uri
|
||||
// straight through, so existing invocations are unchanged.
|
||||
let scope = crate::scope::resolve_scope(
|
||||
&crate::operator::load_operator_config()?,
|
||||
crate::planes::Capability::Any,
|
||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||
)?;
|
||||
require_graph_for_multi_graph_server(&scope).await?;
|
||||
let (server, graph, uri) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
scope.uri,
|
||||
);
|
||||
let via_server = server.is_some();
|
||||
let uri = apply_server_flag(server, graph, uri)?;
|
||||
let token = resolve_remote_bearer_token(uri.as_deref())?;
|
||||
let uri = crate::helpers::resolve_uri(uri)?;
|
||||
reject_positional_remote(via_server, &uri)?;
|
||||
if is_remote_uri(&uri) {
|
||||
Ok(GraphClient::Remote {
|
||||
http: build_http_client()?,
|
||||
base_url: uri,
|
||||
token,
|
||||
})
|
||||
} else {
|
||||
Ok(GraphClient::Embedded { uri, actor: None })
|
||||
}
|
||||
}
|
||||
|
||||
/// Write-path factory: the same addressing/credential resolution as
|
||||
/// `resolve()`, but through the stricter `resolve_cli_graph` (which
|
||||
/// carries `policy_file`/`graph_id`/`selected`), and with the actor
|
||||
/// resolved up front. The embedded arm then opens WITH policy. The
|
||||
/// resolution order matches the write arms exactly: server flag →
|
||||
/// bearer token → graph.
|
||||
pub(crate) async fn resolve_with_policy(
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
uri: Option<String>,
|
||||
cli_as: Option<&str>,
|
||||
profile: Option<&str>,
|
||||
store: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// RFC-011 scope translation (see `resolve`); explicit addressing passes
|
||||
// through unchanged.
|
||||
let scope = crate::scope::resolve_scope(
|
||||
&crate::operator::load_operator_config()?,
|
||||
crate::planes::Capability::Any,
|
||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||
)?;
|
||||
require_graph_for_multi_graph_server(&scope).await?;
|
||||
let (server, graph, uri) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
scope.uri,
|
||||
);
|
||||
let via_server = server.is_some();
|
||||
let uri = apply_server_flag(server, graph, uri)?;
|
||||
let token = resolve_remote_bearer_token(uri.as_deref())?;
|
||||
let resolved = resolve_cli_graph(uri)?;
|
||||
reject_positional_remote(via_server, &resolved.uri)?;
|
||||
if resolved.is_remote {
|
||||
// A served write resolves the actor server-side from the bearer
|
||||
// token; `--as` cannot set identity here and is rejected.
|
||||
if cli_as.is_some() {
|
||||
bail!(
|
||||
"`--as` is not allowed on a served write — the server resolves the actor \
|
||||
from the bearer token. Remove `--as`, or run the write directly against \
|
||||
storage with `--store <uri>`."
|
||||
);
|
||||
}
|
||||
Ok(GraphClient::Remote {
|
||||
http: build_http_client()?,
|
||||
base_url: resolved.uri,
|
||||
token,
|
||||
})
|
||||
} else {
|
||||
let actor = resolve_cli_actor(cli_as)?;
|
||||
Ok(GraphClient::Embedded {
|
||||
uri: resolved.uri,
|
||||
actor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The graph URI (local path / remote base URL) this client addresses.
|
||||
pub(crate) fn uri(&self) -> &str {
|
||||
match self {
|
||||
GraphClient::Embedded { uri, .. } => uri,
|
||||
GraphClient::Remote { base_url, .. } => base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_remote(&self) -> bool {
|
||||
matches!(self, GraphClient::Remote { .. })
|
||||
}
|
||||
|
||||
/// Open the local engine. Direct-store access carries no Cedar policy
|
||||
/// (RFC-011), so both read and write paths open bare; the actor is still
|
||||
/// attributed on the write via the `_as` engine APIs.
|
||||
async fn open_embedded(uri: &str) -> Result<Omnigraph> {
|
||||
Ok(Omnigraph::open(uri).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn branch_list(&self) -> Result<BranchListOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::GET,
|
||||
remote_url(base_url, &["branches"], &[])?,
|
||||
None,
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let db = Omnigraph::open(uri).await?;
|
||||
let mut branches = db.branch_list().await?;
|
||||
branches.sort();
|
||||
Ok(BranchListOutput { branches })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn snapshot(&self, branch: &str) -> Result<SnapshotOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::GET,
|
||||
remote_url(base_url, &["snapshot"], &[("branch", branch)])?,
|
||||
None,
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let db = Omnigraph::open(uri).await?;
|
||||
let snapshot = db.snapshot_of(ReadTarget::branch(branch)).await?;
|
||||
Ok(snapshot_payload(branch, &snapshot))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn schema_source(&self) -> Result<SchemaOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::GET,
|
||||
remote_url(base_url, &["schema"], &[])?,
|
||||
None,
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let db = Omnigraph::open(uri).await?;
|
||||
Ok(SchemaOutput {
|
||||
schema_source: db.schema_source().to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_commits(&self, branch: Option<&str>) -> Result<CommitListOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let url = match branch {
|
||||
Some(branch) => remote_url(base_url, &["commits"], &[("branch", branch)])?,
|
||||
None => remote_url(base_url, &["commits"], &[])?,
|
||||
};
|
||||
remote_json(http, Method::GET, url, None, token.as_deref()).await
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let db = Omnigraph::open(uri).await?;
|
||||
let commits = db
|
||||
.list_commits(branch)
|
||||
.await?
|
||||
.iter()
|
||||
.map(commit_output)
|
||||
.collect::<Vec<_>>();
|
||||
Ok(CommitListOutput { commits })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_commit(&self, commit_id: &str) -> Result<CommitOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::GET,
|
||||
remote_url(base_url, &["commits", commit_id], &[])?,
|
||||
None,
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let db = Omnigraph::open(uri).await?;
|
||||
Ok(commit_output(&db.get_commit(commit_id).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `load` — bulk-load `data` (a file path) onto `branch`, forking from
|
||||
/// `from` if missing. Returns the CLI `LoadOutput`; each arm keeps its
|
||||
/// own mapping (remote sums the wire `IngestOutput.tables`, embedded
|
||||
/// reads the richer `LoadResult` directly) — preserved exactly.
|
||||
pub(crate) async fn load(
|
||||
&self,
|
||||
branch: &str,
|
||||
from: Option<&str>,
|
||||
data: &str,
|
||||
mode: CliLoadMode,
|
||||
) -> Result<LoadOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let data = std::fs::read_to_string(data)?;
|
||||
// RFC-009 Phase 5: the canonical `load` verb targets the
|
||||
// canonical `/load` route (the deprecated `ingest` verb below
|
||||
// still rides `/ingest`).
|
||||
let output = remote_json::<IngestOutput>(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["load"], &[])?,
|
||||
Some(serde_json::to_value(IngestRequest {
|
||||
branch: Some(branch.to_string()),
|
||||
from: from.map(ToOwned::to_owned),
|
||||
mode: Some(mode.into()),
|
||||
data,
|
||||
})?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(load_output_from_tables(base_url, branch, mode.as_str(), &output))
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.load_file_as(branch, from, data, mode.into(), actor.as_deref())
|
||||
.await?;
|
||||
Ok(load_output_from_result(uri, branch, mode.as_str(), &result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ingest` — the deprecated alias of `load`. Same operation, but the
|
||||
/// surfaced shape is the wire `IngestOutput` (printed by
|
||||
/// `print_ingest_human`), so it is its own method. The embedded arm
|
||||
/// echoes `actor_id: None` in the output exactly as the legacy arm did
|
||||
/// (the actor is still attributed on the commit via `load_file_as`).
|
||||
pub(crate) async fn ingest(
|
||||
&self,
|
||||
branch: &str,
|
||||
from: &str,
|
||||
data: &str,
|
||||
mode: CliLoadMode,
|
||||
) -> Result<IngestOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let data = std::fs::read_to_string(data)?;
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["ingest"], &[])?,
|
||||
Some(serde_json::to_value(IngestRequest {
|
||||
branch: Some(branch.to_string()),
|
||||
from: Some(from.to_string()),
|
||||
mode: Some(mode.into()),
|
||||
data,
|
||||
})?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.load_file_as(branch, Some(from), data, mode.into(), actor.as_deref())
|
||||
.await?;
|
||||
Ok(ingest_output(uri, &result, mode.into(), None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `mutate` — run a change query against `branch`. Folds
|
||||
/// `execute_change` / `execute_change_remote` + the legacy request body.
|
||||
pub(crate) async fn mutate(
|
||||
&self,
|
||||
branch: &str,
|
||||
query_source: &str,
|
||||
query_name: Option<&str>,
|
||||
params_json: Option<&Value>,
|
||||
) -> Result<ChangeOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["change"], &[])?,
|
||||
Some(legacy_change_request_body(
|
||||
query_source,
|
||||
query_name,
|
||||
branch,
|
||||
params_json,
|
||||
)),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
|
||||
let params = query_params_from_json(&query_params, params_json)?;
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
let result = db
|
||||
.mutate_as(branch, query_source, &selected_name, ¶ms, actor)
|
||||
.await?;
|
||||
Ok(ChangeOutput {
|
||||
branch: branch.to_string(),
|
||||
query_name: selected_name,
|
||||
affected_nodes: result.affected_nodes,
|
||||
affected_edges: result.affected_edges,
|
||||
actor_id: actor.map(String::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `query` — run a read query against `target`. Folds `execute_read` /
|
||||
/// `execute_read_remote`; the embedded arm opens WITHOUT policy (reads
|
||||
/// never attach one), so this verb resolves via `resolve()`.
|
||||
pub(crate) async fn query(
|
||||
&self,
|
||||
target: ReadTarget,
|
||||
query_source: &str,
|
||||
query_name: Option<&str>,
|
||||
params_json: Option<&Value>,
|
||||
) -> Result<ReadOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let (branch, snapshot) = match &target {
|
||||
ReadTarget::Branch(branch) => (Some(branch.clone()), None),
|
||||
ReadTarget::Snapshot(snapshot) => (None, Some(snapshot.as_str().to_string())),
|
||||
};
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["read"], &[])?,
|
||||
Some(serde_json::to_value(ReadRequest {
|
||||
query_source: query_source.to_string(),
|
||||
query_name: query_name.map(ToOwned::to_owned),
|
||||
params: params_json.cloned(),
|
||||
branch,
|
||||
snapshot,
|
||||
})?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
|
||||
let params = query_params_from_json(&query_params, params_json)?;
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.query(target.clone(), query_source, &selected_name, ¶ms)
|
||||
.await?;
|
||||
Ok(read_output(selected_name, &target, result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `invoke_named` — run a stored query **by catalog name** (RFC-011 D3).
|
||||
/// Served-only: the catalog is server-owned, so a `--store` (embedded)
|
||||
/// scope has nothing to resolve the name against. `expect_mutation` carries
|
||||
/// the verb's asserted kind; the server rejects a mismatch (400) before
|
||||
/// running, so the response is exactly the expected envelope — the caller
|
||||
/// deserializes it as the concrete `T` (`ReadOutput` for `query`,
|
||||
/// `ChangeOutput` for `mutate`), sidestepping the untagged wire enum.
|
||||
pub(crate) async fn invoke_named<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
name: &str,
|
||||
expect_mutation: bool,
|
||||
params_json: Option<&Value>,
|
||||
branch: Option<String>,
|
||||
snapshot: Option<String>,
|
||||
) -> Result<T> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let body = InvokeStoredQueryRequest {
|
||||
params: params_json.cloned(),
|
||||
branch,
|
||||
snapshot,
|
||||
expect_mutation: Some(expect_mutation),
|
||||
};
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["queries", name], &[])?,
|
||||
Some(serde_json::to_value(body)?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { .. } => bail!(
|
||||
"by-name invocation needs a server (the stored-query catalog is \
|
||||
server-owned); use -e '<gq>' or --query <file> for an ad-hoc query \
|
||||
against --store, or address a server with --server / --profile"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn branch_create_from(
|
||||
&self,
|
||||
from: &str,
|
||||
name: &str,
|
||||
) -> Result<BranchCreateOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["branches"], &[])?,
|
||||
Some(serde_json::to_value(BranchCreateRequest {
|
||||
from: Some(from.to_string()),
|
||||
name: name.to_string(),
|
||||
})?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
db.branch_create_from_as(ReadTarget::branch(from), name, actor)
|
||||
.await?;
|
||||
Ok(BranchCreateOutput {
|
||||
uri: uri.clone(),
|
||||
from: from.to_string(),
|
||||
name: name.to_string(),
|
||||
actor_id: actor.map(String::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn branch_delete(&self, name: &str) -> Result<BranchDeleteOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::DELETE,
|
||||
remote_url(base_url, &["branches", name], &[])?,
|
||||
None,
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
db.branch_delete_as(name, actor).await?;
|
||||
Ok(BranchDeleteOutput {
|
||||
uri: uri.clone(),
|
||||
name: name.to_string(),
|
||||
actor_id: actor.map(String::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn branch_merge(&self, source: &str, into: &str) -> Result<BranchMergeOutput> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["branches", "merge"], &[])?,
|
||||
Some(serde_json::to_value(BranchMergeRequest {
|
||||
source: source.to_string(),
|
||||
target: Some(into.to_string()),
|
||||
})?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
let outcome = db.branch_merge_as(source, into, actor).await?;
|
||||
Ok(BranchMergeOutput {
|
||||
source: source.to_string(),
|
||||
target: into.to_string(),
|
||||
outcome: outcome.into(),
|
||||
actor_id: actor.map(String::from),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `apply_schema` — apply `schema_source`. The embedded arm runs the
|
||||
/// caller's catalog validator (stored-query registry check) inside the
|
||||
/// engine's `apply_schema_as_with_catalog_check`; the remote arm runs
|
||||
/// the server's own check and IGNORES `validate`. The `impl FnOnce`
|
||||
/// validator is exactly why this is an enum, not a trait (non-object-
|
||||
/// safe).
|
||||
pub(crate) async fn apply_schema<F>(
|
||||
&self,
|
||||
schema_source: &str,
|
||||
allow_data_loss: bool,
|
||||
validate: F,
|
||||
) -> Result<SchemaApplyOutput>
|
||||
where
|
||||
F: FnOnce(&Catalog) -> omnigraph::error::Result<()>,
|
||||
{
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
// MR-694 PR B: SchemaApplyRequest carries allow_data_loss so
|
||||
// Hard-mode drops are no longer CLI-only; the server's
|
||||
// `server_schema_apply` honors it (and runs its own catalog
|
||||
// check, so `validate` does not apply here).
|
||||
remote_json::<SchemaApplyOutput>(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["schema", "apply"], &[])?,
|
||||
Some(serde_json::to_value(SchemaApplyRequest {
|
||||
schema_source: schema_source.to_string(),
|
||||
allow_data_loss,
|
||||
})?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.apply_schema_as_with_catalog_check(
|
||||
schema_source,
|
||||
omnigraph::db::SchemaApplyOptions { allow_data_loss },
|
||||
actor.as_deref(),
|
||||
validate,
|
||||
)
|
||||
.await?;
|
||||
Ok(schema_apply_output(uri, result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `export` — stream the branch as JSONL into `writer`. The streaming
|
||||
/// shape (a `W: Write`, not a returned DTO) is why this lands in 3c
|
||||
/// rather than 3b. Opens WITHOUT policy (like reads), so it is reached
|
||||
/// via `resolve()`; the Embedded arm opens bare. The Remote arm streams
|
||||
/// the chunked response body straight through (no buffering the whole
|
||||
/// export in memory).
|
||||
pub(crate) async fn export<W: Write>(
|
||||
&self,
|
||||
branch: &str,
|
||||
type_names: &[String],
|
||||
table_keys: &[String],
|
||||
writer: &mut W,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let request = apply_bearer_token(
|
||||
http.request(Method::POST, remote_url(base_url, &["export"], &[])?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.json(&ExportRequest {
|
||||
branch: Some(branch.to_string()),
|
||||
type_names: type_names.to_vec(),
|
||||
table_keys: table_keys.to_vec(),
|
||||
});
|
||||
let mut response = request.send().await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let text = response.text().await?;
|
||||
if let Ok(error) = serde_json::from_str::<ErrorOutput>(&text) {
|
||||
bail!(error.error);
|
||||
}
|
||||
bail!("server returned {}: {}", status, text);
|
||||
}
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
writer.write_all(&chunk)?;
|
||||
}
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let db = Omnigraph::open(uri).await?;
|
||||
db.export_jsonl_to_writer(branch, type_names, table_keys, writer)
|
||||
.await?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `graphs list` — enumerate the graphs a remote multi-graph server
|
||||
/// serves (`GET /graphs`). Remote-only by design: there is no local
|
||||
/// enumeration endpoint, so the Embedded arm fails loudly. Routing it
|
||||
/// through the enum still buys the shared `resolve()` addressing/token
|
||||
/// preamble.
|
||||
pub(crate) async fn list_graphs(&self) -> Result<GraphListResponse> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
remote_json(
|
||||
http,
|
||||
Method::GET,
|
||||
remote_url(base_url, &["graphs"], &[])?,
|
||||
None,
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { .. } => bail!(
|
||||
"`omnigraph graphs list` requires a remote multi-graph server \
|
||||
(--server <url>). To enumerate the graphs in a cluster, run \
|
||||
`omnigraph cluster status --config <dir>`."
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ use omnigraph::embedding::EmbeddingClient;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const DEFAULT_EMBED_MODEL: &str = "gemini-embedding-2-preview";
|
||||
|
||||
#[derive(Debug, Args, Clone)]
|
||||
pub(crate) struct EmbedArgs {
|
||||
/// Seed manifest path
|
||||
|
|
@ -83,6 +85,8 @@ impl EmbedMode {
|
|||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct EmbedSpec {
|
||||
#[serde(default = "default_embed_model")]
|
||||
model: String,
|
||||
dimension: usize,
|
||||
types: BTreeMap<String, EmbedTypeSpec>,
|
||||
}
|
||||
|
|
@ -176,6 +180,13 @@ pub(crate) fn resolve_embed_job(args: &EmbedArgs) -> Result<EmbedJob> {
|
|||
(input, output, spec)
|
||||
};
|
||||
|
||||
if spec.model != DEFAULT_EMBED_MODEL {
|
||||
bail!(
|
||||
"only {} is supported for explicit seed embeddings right now",
|
||||
DEFAULT_EMBED_MODEL
|
||||
);
|
||||
}
|
||||
|
||||
Ok(EmbedJob {
|
||||
input,
|
||||
output,
|
||||
|
|
@ -294,14 +305,7 @@ pub(crate) async fn run_embed_job(job: &EmbedJob) -> Result<EmbedOutput> {
|
|||
cleaned_rows,
|
||||
mode: job.mode.as_str(!job.selectors.is_empty()),
|
||||
dimension: job.spec.dimension,
|
||||
// The embedding model is resolved solely from the provider config; the
|
||||
// spec carries no model field, so there is no second source of truth to
|
||||
// silently disagree with the API. Report what was actually used (empty
|
||||
// for `--clean`, which builds no client).
|
||||
model: client
|
||||
.as_ref()
|
||||
.map(|c| c.config().model.clone())
|
||||
.unwrap_or_default(),
|
||||
model: job.spec.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +315,10 @@ fn temp_output_path(output: &Path) -> PathBuf {
|
|||
PathBuf::from(temp)
|
||||
}
|
||||
|
||||
fn default_embed_model() -> String {
|
||||
DEFAULT_EMBED_MODEL.to_string()
|
||||
}
|
||||
|
||||
fn load_embed_spec(path: &Path) -> Result<EmbedSpec> {
|
||||
Ok(serde_json::from_str(&fs::read_to_string(path)?)?)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,124 +0,0 @@
|
|||
//! In-source test suite for the CLI binary (moved verbatim from
|
||||
//! main.rs; `use super::*` resolves through the #[path] declaration).
|
||||
|
||||
use super::{
|
||||
DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, legacy_change_request_body,
|
||||
normalize_bearer_token, resolve_remote_bearer_token,
|
||||
};
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn legacy_change_request_body_uses_legacy_field_names() {
|
||||
// `mutate`'s remote arm hits `POST /change`, which old
|
||||
// `omnigraph-server` builds deserialize as `ChangeRequest` with
|
||||
// **required** `query_source` and optional `query_name` keys.
|
||||
// Newer servers accept both spellings via serde alias, but a
|
||||
// newer CLI must still emit the legacy keys on the wire so it
|
||||
// can talk to an old server during a rolling upgrade.
|
||||
let body = legacy_change_request_body(
|
||||
"query insert_person($n: String) { insert Person { name: $n } }",
|
||||
Some("insert_person"),
|
||||
"main",
|
||||
Some(&json!({ "n": "Alice" })),
|
||||
);
|
||||
assert_eq!(
|
||||
body["query_source"].as_str(),
|
||||
Some("query insert_person($n: String) { insert Person { name: $n } }"),
|
||||
);
|
||||
assert_eq!(body["query_name"].as_str(), Some("insert_person"));
|
||||
assert_eq!(body["branch"].as_str(), Some("main"));
|
||||
assert_eq!(body["params"]["n"].as_str(), Some("Alice"));
|
||||
// Crucially, the **new** field names must NOT appear -- old
|
||||
// servers would silently treat them as unknown fields and then
|
||||
// fail on missing required `query_source`.
|
||||
assert!(
|
||||
body.get("query").is_none(),
|
||||
"legacy /change body must not carry the renamed `query` key; got {body}"
|
||||
);
|
||||
assert!(
|
||||
body.get("name").is_none(),
|
||||
"legacy /change body must not carry the renamed `name` key; got {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_change_request_body_omits_optional_fields_when_unset() {
|
||||
let body = legacy_change_request_body(
|
||||
"query find() { match { $p: Person } return { $p.name } }",
|
||||
None,
|
||||
"main",
|
||||
None,
|
||||
);
|
||||
assert_eq!(body["branch"].as_str(), Some("main"));
|
||||
assert!(body.get("query_name").is_none());
|
||||
assert!(body.get("params").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_bearer_token_adds_header_when_configured() {
|
||||
let client = reqwest::Client::new();
|
||||
let request = apply_bearer_token(client.get("http://example.com"), Some("demo-token"))
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Bearer demo-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_bearer_token_leaves_request_unchanged_when_not_configured() {
|
||||
let client = reqwest::Client::new();
|
||||
let request = apply_bearer_token(client.get("http://example.com"), None)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(request.headers().get(AUTHORIZATION).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_bearer_token_trims_and_filters_blank_values() {
|
||||
assert_eq!(normalize_bearer_token(None), None);
|
||||
assert_eq!(normalize_bearer_token(Some(" ".to_string())), None);
|
||||
assert_eq!(
|
||||
normalize_bearer_token(Some(" demo-token ".to_string())).as_deref(),
|
||||
Some("demo-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_remote_bearer_token_falls_back_to_default_env() {
|
||||
// RFC-011: with no operator server matching the URL, the only chain
|
||||
// left is the default `OMNIGRAPH_BEARER_TOKEN` env (no omnigraph.yaml
|
||||
// scoped chain). Hermetic: no operator config is read for a literal URL
|
||||
// that matches no `servers:` entry.
|
||||
let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV);
|
||||
let previous_home = std::env::var_os("OMNIGRAPH_HOME");
|
||||
unsafe {
|
||||
std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, "global-token");
|
||||
std::env::set_var("OMNIGRAPH_HOME", "/nonexistent/omnigraph-test-home");
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve_remote_bearer_token(Some("https://override.example.com"))
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("global-token")
|
||||
);
|
||||
|
||||
unsafe {
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, value);
|
||||
} else {
|
||||
std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV);
|
||||
}
|
||||
if let Some(value) = previous_home {
|
||||
std::env::set_var("OMNIGRAPH_HOME", value);
|
||||
} else {
|
||||
std::env::remove_var("OMNIGRAPH_HOME");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,841 +0,0 @@
|
|||
//! The operator config surface (RFC-007): `~/.omnigraph/config.yaml` — who
|
||||
//! the operator IS (identity, ergonomics), never what the system is (that's
|
||||
//! cluster config) and never a project file (nothing here arrives with a
|
||||
//! repo checkout).
|
||||
//!
|
||||
//! PR-1 scope: `operator.actor` + `defaults.output`. Unknown keys WARN and
|
||||
//! are preserved-by-ignoring — a file written for a newer CLI (servers,
|
||||
//! aliases, credentials keys from later slices) must load cleanly on this
|
||||
//! one. Contrast with `cluster.yaml`, where unknown keys are fatal because
|
||||
//! they change what a plan means.
|
||||
//!
|
||||
//! This module is CLI-only by design: the server never reads operator
|
||||
//! config (server-side identity comes from bearer auth — invariant 11
|
||||
//! holds by construction).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::{bail, eyre};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::read_format::{ReadOutputFormat, TableCellLayout};
|
||||
|
||||
pub(crate) const OPERATOR_HOME_ENV: &str = "OMNIGRAPH_HOME";
|
||||
pub(crate) const OPERATOR_DIR: &str = ".omnigraph";
|
||||
pub(crate) const OPERATOR_CONFIG_FILE: &str = "config.yaml";
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) operator: OperatorIdentity,
|
||||
#[serde(default)]
|
||||
pub(crate) defaults: OperatorDefaults,
|
||||
/// Operator-owned endpoint definitions (RFC-007 §D2/§D4): name → url.
|
||||
/// The name keys the credential chain; nothing a repo checkout supplies
|
||||
/// can redefine an entry here. No tokens in this file, ever.
|
||||
#[serde(default)]
|
||||
pub(crate) servers: BTreeMap<String, OperatorServer>,
|
||||
/// Personal alias bindings (RFC-007 PR 3); see OperatorAlias.
|
||||
#[serde(default)]
|
||||
pub(crate) aliases: BTreeMap<String, OperatorAlias>,
|
||||
/// Named scope bundles (RFC-011): each binds exactly one of
|
||||
/// {server, cluster, store} plus an optional default graph. Config data,
|
||||
/// not state — selecting one (`--profile`/`OMNIGRAPH_PROFILE`) fills in a
|
||||
/// command's omitted addressing; it never puts you "in" a mode.
|
||||
#[serde(default)]
|
||||
pub(crate) profiles: BTreeMap<String, OperatorProfile>,
|
||||
/// Managed-cluster storage roots (RFC-011): name → root URI. The ONLY
|
||||
/// place a storage root appears in operator config — admin-only and
|
||||
/// opt-in; a normal operator's file has none.
|
||||
#[serde(default)]
|
||||
pub(crate) clusters: BTreeMap<String, OperatorCluster>,
|
||||
/// Everything this CLI version doesn't know. Warned once at load,
|
||||
/// otherwise ignored (forward compatibility within the operator layer).
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// A personal alias: a pure BINDING to a stored query on a named server —
|
||||
/// never content, never a file (RFC-007 §D2 "Aliases are bindings, not
|
||||
/// content"). The stored query is the team's contract; the alias, its
|
||||
/// defaults, and its name are the operator's.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OperatorAlias {
|
||||
/// Names an entry under `servers:`.
|
||||
pub(crate) server: String,
|
||||
/// Graph id for multi-graph servers (appends `/graphs/<id>`).
|
||||
pub(crate) graph: Option<String>,
|
||||
/// The STORED query's name on that server.
|
||||
pub(crate) query: String,
|
||||
/// Positional CLI args bind to these param names, in order.
|
||||
#[serde(default)]
|
||||
pub(crate) args: Vec<String>,
|
||||
/// Fixed default params; positionals and `--params` override per key.
|
||||
#[serde(default)]
|
||||
pub(crate) params: serde_yaml::Mapping,
|
||||
pub(crate) format: Option<ReadOutputFormat>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct OperatorServer {
|
||||
pub(crate) url: String,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorIdentity {
|
||||
/// Default actor for every `--as` cascade (CLI direct-engine writes and
|
||||
/// cluster commands alike): `--as` > this > none.
|
||||
pub(crate) actor: Option<String>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorDefaults {
|
||||
/// Default read output format, below every more-specific source.
|
||||
pub(crate) output: Option<ReadOutputFormat>,
|
||||
/// Table rendering preferences for `--format table`.
|
||||
pub(crate) table_max_column_width: Option<usize>,
|
||||
pub(crate) table_cell_layout: Option<TableCellLayout>,
|
||||
/// Default server scope (RFC-011): the everyday addressing when no
|
||||
/// `--profile` / primitive / legacy address is given. Names an entry
|
||||
/// under `servers:`. Mutually exclusive with `store` — a scope binds one
|
||||
/// entity.
|
||||
pub(crate) server: Option<String>,
|
||||
/// Default **store** scope (RFC-011): a `file://` / `s3://` graph storage
|
||||
/// URI used as the zero-flag local default for graph commands when no
|
||||
/// `--profile` / primitive address is given. The local-dev counterpart of
|
||||
/// `server`; mutually exclusive with it.
|
||||
pub(crate) store: Option<String>,
|
||||
/// Default graph selected within a server/cluster scope when no
|
||||
/// `--graph` is passed (RFC-011).
|
||||
pub(crate) default_graph: Option<String>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// A named scope bundle (RFC-011): exactly one of {server, cluster, store}
|
||||
/// plus an optional default graph. Validated on use (`binding()`), not at
|
||||
/// parse time, so an unknown CLI's profile still loads.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorProfile {
|
||||
/// Names an entry under `servers:` — a served scope.
|
||||
pub(crate) server: Option<String>,
|
||||
/// Names an entry under `clusters:` — a privileged direct cluster scope.
|
||||
pub(crate) cluster: Option<String>,
|
||||
/// A single graph's storage URI — a direct store scope.
|
||||
pub(crate) store: Option<String>,
|
||||
/// Default graph within a server/cluster scope (ignored for a store,
|
||||
/// which is already one graph).
|
||||
pub(crate) default_graph: Option<String>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// A managed-cluster storage root (RFC-011).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorCluster {
|
||||
/// The cluster's storage-root URI (`file://` / `s3://`).
|
||||
pub(crate) root: String,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// The one entity a profile (or flat default) binds. Exactly one variant —
|
||||
/// the scope resolver consumes this; "exactly one of server/cluster/store"
|
||||
/// is enforced when producing it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ScopeBinding {
|
||||
/// Served scope: a server name (resolved against `servers:`) or a literal URL.
|
||||
Server(String),
|
||||
/// Direct cluster scope: a cluster name (resolved against `clusters:`) or a
|
||||
/// literal root URI.
|
||||
Cluster(String),
|
||||
/// Direct store scope: a single graph's storage URI.
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl OperatorConfig {
|
||||
pub(crate) fn actor(&self) -> Option<&str> {
|
||||
self.operator.actor.as_deref()
|
||||
}
|
||||
|
||||
pub(crate) fn output(&self) -> Option<ReadOutputFormat> {
|
||||
self.defaults.output
|
||||
}
|
||||
|
||||
/// The gh-host model: which operator server (if any) does this request
|
||||
/// URL belong to? Longest-prefix match after trailing-slash
|
||||
/// normalization, so `url: http://h:8080` matches
|
||||
/// `http://h:8080/graphs/spike` but never `http://h:8080-evil`.
|
||||
pub(crate) fn find_server_for_url(&self, request_url: &str) -> Option<&str> {
|
||||
let request = request_url.trim_end_matches('/');
|
||||
let mut best: Option<(&str, usize)> = None;
|
||||
for (name, server) in &self.servers {
|
||||
let base = server.url.trim_end_matches('/');
|
||||
let matches = request == base
|
||||
|| request
|
||||
.strip_prefix(base)
|
||||
.is_some_and(|rest| rest.starts_with('/'));
|
||||
if matches && best.is_none_or(|(_, len)| base.len() > len) {
|
||||
best = Some((name, base.len()));
|
||||
}
|
||||
}
|
||||
best.map(|(name, _)| name)
|
||||
}
|
||||
|
||||
/// A named profile, if defined (RFC-011).
|
||||
pub(crate) fn profile(&self, name: &str) -> Option<&OperatorProfile> {
|
||||
self.profiles.get(name)
|
||||
}
|
||||
|
||||
/// The storage root of a named cluster, if defined (RFC-011).
|
||||
pub(crate) fn cluster_root(&self, name: &str) -> Option<&str> {
|
||||
self.clusters.get(name).map(|c| c.root.as_str())
|
||||
}
|
||||
|
||||
/// The flat-default server scope name, if set (RFC-011).
|
||||
pub(crate) fn default_server(&self) -> Option<&str> {
|
||||
self.defaults.server.as_deref()
|
||||
}
|
||||
|
||||
/// The flat-default store scope URI, if set (RFC-011) — the zero-flag
|
||||
/// local-dev default.
|
||||
pub(crate) fn default_store(&self) -> Option<&str> {
|
||||
self.defaults.store.as_deref()
|
||||
}
|
||||
|
||||
/// The flat-default graph within a server/cluster scope, if set (RFC-011).
|
||||
pub(crate) fn default_graph(&self) -> Option<&str> {
|
||||
self.defaults.default_graph.as_deref()
|
||||
}
|
||||
|
||||
/// A scope binds one entity (Decision 6): `defaults.server` and
|
||||
/// `defaults.store` are mutually exclusive, and a `store` (already a single
|
||||
/// graph) cannot carry a `default_graph`. Both are refused loudly rather
|
||||
/// than silently dropped.
|
||||
fn validate_defaults(&self) -> Result<()> {
|
||||
if self.defaults.server.is_some() && self.defaults.store.is_some() {
|
||||
bail!(
|
||||
"operator config `defaults` sets both `server` and `store` — a default scope \
|
||||
binds one entity; keep one (use a `profile` if you need both)"
|
||||
);
|
||||
}
|
||||
if self.defaults.store.is_some() && self.defaults.default_graph.is_some() {
|
||||
bail!(
|
||||
"operator config `defaults` sets both `store` and `default_graph` — a store is \
|
||||
already a single graph; drop `default_graph` (it applies only to a server/cluster scope)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl OperatorProfile {
|
||||
/// The single entity this profile binds, or a loud error if it binds zero
|
||||
/// or more than one of {server, cluster, store} (Decision 6: a scope binds
|
||||
/// exactly one entity). Validated here, on use, rather than at parse time.
|
||||
pub(crate) fn binding(&self, profile_name: &str) -> Result<ScopeBinding> {
|
||||
let set: Vec<&str> = [
|
||||
self.server.as_ref().map(|_| "server"),
|
||||
self.cluster.as_ref().map(|_| "cluster"),
|
||||
self.store.as_ref().map(|_| "store"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
match set.as_slice() {
|
||||
["server"] => Ok(ScopeBinding::Server(self.server.clone().unwrap())),
|
||||
["cluster"] => Ok(ScopeBinding::Cluster(self.cluster.clone().unwrap())),
|
||||
["store"] => Ok(ScopeBinding::Store(self.store.clone().unwrap())),
|
||||
[] => Err(eyre!(
|
||||
"profile '{profile_name}' binds no scope; set exactly one of \
|
||||
`server`, `cluster`, or `store`"
|
||||
)),
|
||||
many => Err(eyre!(
|
||||
"profile '{profile_name}' binds {} scopes ({}); a profile must \
|
||||
bind exactly one of `server`, `cluster`, or `store`",
|
||||
many.len(),
|
||||
many.join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator dir: `$OMNIGRAPH_HOME` if set (tilde-expanded), else
|
||||
/// `~/.omnigraph`. Returns None when no home directory is resolvable
|
||||
/// (degenerate environments — the layer is simply absent).
|
||||
pub(crate) fn operator_dir() -> Option<PathBuf> {
|
||||
if let Some(home_override) = env::var_os(OPERATOR_HOME_ENV) {
|
||||
let raw = home_override.to_string_lossy().into_owned();
|
||||
return Some(expand_tilde(&raw));
|
||||
}
|
||||
env::home_dir().map(|home| home.join(OPERATOR_DIR))
|
||||
}
|
||||
|
||||
/// Load the operator layer. Absent file (or unresolvable home) is an empty
|
||||
/// layer, never an error; a present-but-malformed file is a loud error (the
|
||||
/// operator owns it and can fix it); unknown keys warn to stderr once.
|
||||
pub(crate) fn load_operator_config() -> Result<OperatorConfig> {
|
||||
let Some(dir) = operator_dir() else {
|
||||
return Ok(OperatorConfig::default());
|
||||
};
|
||||
load_operator_config_at(&dir.join(OPERATOR_CONFIG_FILE))
|
||||
}
|
||||
|
||||
pub(crate) fn load_operator_config_at(path: &Path) -> Result<OperatorConfig> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(OperatorConfig::default());
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(eyre!(
|
||||
"could not read operator config '{}': {err}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
let config: OperatorConfig = serde_yaml::from_str(&text).map_err(|err| {
|
||||
eyre!(
|
||||
"could not parse operator config '{}': {err}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
for warning in config.unknown_key_warnings() {
|
||||
eprintln!("warning: {warning} in operator config '{}'", path.display());
|
||||
}
|
||||
config.validate_defaults()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
impl OperatorConfig {
|
||||
fn unknown_key_warnings(&self) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let mut collect = |mapping: &serde_yaml::Mapping, prefix: &str| {
|
||||
for key in mapping.keys() {
|
||||
if let Some(name) = key.as_str() {
|
||||
warnings.push(format!(
|
||||
"unknown key `{prefix}{name}` (newer CLI feature or typo); ignored"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(&self.unknown, "");
|
||||
collect(&self.operator.unknown, "operator.");
|
||||
collect(&self.defaults.unknown, "defaults.");
|
||||
for (name, server) in &self.servers {
|
||||
collect(&server.unknown, &format!("servers.{name}."));
|
||||
}
|
||||
for (name, alias) in &self.aliases {
|
||||
collect(&alias.unknown, &format!("aliases.{name}."));
|
||||
}
|
||||
for (name, profile) in &self.profiles {
|
||||
collect(&profile.unknown, &format!("profiles.{name}."));
|
||||
}
|
||||
for (name, cluster) in &self.clusters {
|
||||
collect(&cluster.unknown, &format!("clusters.{name}."));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
// ---- keyed credentials (RFC-007 §D4) ----
|
||||
|
||||
pub(crate) const CREDENTIALS_FILE: &str = "credentials";
|
||||
const TOKEN_ENV_PREFIX: &str = "OMNIGRAPH_TOKEN_";
|
||||
|
||||
pub(crate) fn credentials_path() -> Option<PathBuf> {
|
||||
operator_dir().map(|dir| dir.join(CREDENTIALS_FILE))
|
||||
}
|
||||
|
||||
/// `intel-dev` → `OMNIGRAPH_TOKEN_INTEL_DEV`.
|
||||
pub(crate) fn token_env_name(server: &str) -> String {
|
||||
let mut name = String::from(TOKEN_ENV_PREFIX);
|
||||
for c in server.chars() {
|
||||
name.push(match c {
|
||||
'-' => '_',
|
||||
other => other.to_ascii_uppercase(),
|
||||
});
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// The keyed token chain for a named server (§D4 steps 1–2):
|
||||
/// `OMNIGRAPH_TOKEN_<NAME>` env → `[<name>]` in the credentials file.
|
||||
/// `Ok(None)` means "no keyed token" — callers fall through to the legacy
|
||||
/// chain; a present-but-unreadable/over-permissive credentials file is a
|
||||
/// loud error, never a silent skip.
|
||||
pub(crate) fn resolve_keyed_token(server: &str) -> Result<Option<String>> {
|
||||
if let Ok(token) = env::var(token_env_name(server)) {
|
||||
let token = token.trim();
|
||||
if !token.is_empty() {
|
||||
return Ok(Some(token.to_string()));
|
||||
}
|
||||
}
|
||||
let Some(path) = credentials_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
read_credential_at(&path, server)
|
||||
}
|
||||
|
||||
pub(crate) fn read_credential_at(path: &Path, server: &str) -> Result<Option<String>> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(err) => {
|
||||
return Err(eyre!(
|
||||
"could not read credentials file '{}': {err}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
refuse_over_permissive(path)?;
|
||||
let mut in_section = false;
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(section) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
in_section = section.trim() == server;
|
||||
continue;
|
||||
}
|
||||
if in_section {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if key.trim() == "token" {
|
||||
let value = unquote(value.trim());
|
||||
if value.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some(value.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Write (or rotate) one server's token, preserving every other section.
|
||||
/// Temp file + rename (#139 finding 7), created 0600.
|
||||
pub(crate) fn write_credential(server: &str, token: &str) -> Result<PathBuf> {
|
||||
let path = credentials_path()
|
||||
.ok_or_else(|| eyre!("no home directory resolvable for the credentials file"))?;
|
||||
rewrite_credentials_at(&path, server, Some(token))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Remove one server's section. Idempotent: absent file or section is fine.
|
||||
pub(crate) fn remove_credential(server: &str) -> Result<PathBuf> {
|
||||
let path = credentials_path()
|
||||
.ok_or_else(|| eyre!("no home directory resolvable for the credentials file"))?;
|
||||
rewrite_credentials_at(&path, server, None)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub(crate) fn rewrite_credentials_at(
|
||||
path: &Path,
|
||||
server: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let existing = match std::fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
refuse_over_permissive(path)?;
|
||||
text
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(err) => {
|
||||
return Err(eyre!(
|
||||
"could not read credentials file '{}': {err}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Drop the target section (if present), keep everything else verbatim.
|
||||
let mut out = String::new();
|
||||
let mut in_target = false;
|
||||
for line in existing.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(section) = trimmed.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
in_target = section.trim() == server;
|
||||
if in_target {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !in_target {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
if let Some(token) = token {
|
||||
if !out.is_empty() && !out.ends_with("\n\n") {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("[{server}]\ntoken = {token}\n"));
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
|
||||
write_owner_only(&tmp, &out)?;
|
||||
std::fs::rename(&tmp, path).map_err(|err| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
eyre!(
|
||||
"could not move credentials file into place '{}': {err}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_owner_only(path: &Path, content: &str) -> Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
file.write_all(content.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_owner_only(path: &Path, content: &str) -> Result<()> {
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Secrets are operator-private: refuse a credentials file other accounts
|
||||
/// can read (the chain errs loudly rather than using a leaked secret).
|
||||
#[cfg(unix)]
|
||||
fn refuse_over_permissive(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(path)?.permissions().mode();
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(eyre!(
|
||||
"credentials file '{}' is group/world-accessible (mode {:o}); run `chmod 600 {}`",
|
||||
path.display(),
|
||||
mode & 0o777,
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn refuse_over_permissive(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unquote(value: &str) -> &str {
|
||||
if value.len() >= 2
|
||||
&& ((value.starts_with('"') && value.ends_with('"'))
|
||||
|| (value.starts_with('\'') && value.ends_with('\'')))
|
||||
{
|
||||
&value[1..value.len() - 1]
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand a leading `~` / `~/` to the home directory (PR #139 finding 9:
|
||||
/// a literal `./~/…` path silently created a directory named `~`).
|
||||
pub(crate) fn expand_tilde(raw: &str) -> PathBuf {
|
||||
if raw == "~" {
|
||||
return env::home_dir().unwrap_or_else(|| PathBuf::from(raw));
|
||||
}
|
||||
if let Some(rest) = raw.strip_prefix("~/") {
|
||||
if let Some(home) = env::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
}
|
||||
PathBuf::from(raw)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn absent_file_is_an_empty_layer() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = load_operator_config_at(&dir.path().join("config.yaml")).unwrap();
|
||||
assert!(config.actor().is_none());
|
||||
assert!(config.output().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_identity_and_defaults() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"operator:\n actor: act-andrew\ndefaults:\n output: json\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.actor(), Some("act-andrew"));
|
||||
assert_eq!(config.output(), Some(ReadOutputFormat::Json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_store_parses_and_is_accessible() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(&path, "defaults:\n store: file:///tmp/dev.omni\n").unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.default_store(), Some("file:///tmp/dev.omni"));
|
||||
assert_eq!(config.default_server(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_server_and_store_together_is_a_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"defaults:\n server: prod\n store: file:///tmp/dev.omni\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_operator_config_at(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("binds one entity"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_store_with_default_graph_is_a_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"defaults:\n store: file:///tmp/dev.omni\n default_graph: knowledge\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_operator_config_at(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("already a single graph"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_warn_but_load() {
|
||||
// A file written for a later slice (servers/aliases) must load
|
||||
// cleanly today — warn-only forward compatibility.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"operator:\n actor: act-a\n color: green\nservers:\n prod:\n url: https://example.com\naliases: {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.actor(), Some("act-a"));
|
||||
let warnings = config.unknown_key_warnings();
|
||||
// `servers` (PR 2) and `aliases` (PR 3) are known keys now.
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("`operator.color`")));
|
||||
assert_eq!(config.servers["prod"].url, "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profiles_clusters_and_scope_defaults() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
let yaml = "\
|
||||
defaults:
|
||||
server: prod
|
||||
default_graph: knowledge
|
||||
servers:
|
||||
prod:
|
||||
url: https://example.com
|
||||
clusters:
|
||||
brain:
|
||||
root: s3://acme/clusters/brain
|
||||
profiles:
|
||||
staging:
|
||||
server: staging
|
||||
default_graph: knowledge
|
||||
brain-admin:
|
||||
cluster: brain
|
||||
default_graph: knowledge
|
||||
";
|
||||
fs::write(&path, yaml).unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.default_server(), Some("prod"));
|
||||
assert_eq!(config.default_graph(), Some("knowledge"));
|
||||
assert_eq!(config.cluster_root("brain"), Some("s3://acme/clusters/brain"));
|
||||
assert_eq!(
|
||||
config.profile("staging").unwrap().binding("staging").unwrap(),
|
||||
ScopeBinding::Server("staging".into())
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.profile("brain-admin")
|
||||
.unwrap()
|
||||
.binding("brain-admin")
|
||||
.unwrap(),
|
||||
ScopeBinding::Cluster("brain".into())
|
||||
);
|
||||
// No unknown-key warnings for the new blocks.
|
||||
assert!(config.unknown_key_warnings().is_empty(), "{:?}", config.unknown_key_warnings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_binding_rejects_zero_or_multiple_entities() {
|
||||
let none = OperatorProfile::default();
|
||||
let err = none.binding("p").unwrap_err().to_string();
|
||||
assert!(err.contains("binds no scope"), "{err}");
|
||||
|
||||
let two = OperatorProfile {
|
||||
server: Some("prod".into()),
|
||||
store: Some("graph.omni".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = two.binding("p").unwrap_err().to_string();
|
||||
assert!(err.contains("binds 2 scopes"), "{err}");
|
||||
assert!(err.contains("server") && err.contains("store"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_in_a_profile_warn() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"profiles:\n p:\n server: prod\n flavour: spicy\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
let warnings = config.unknown_key_warnings();
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("`profiles.p.flavour`")),
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_yaml_is_a_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(&path, "operator: [not, a, mapping\n").unwrap();
|
||||
let err = load_operator_config_at(&path).unwrap_err();
|
||||
assert!(err.to_string().contains("could not parse operator config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_server_for_url_longest_prefix_no_substring_traps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"servers:\n dev:\n url: http://h:8080\n dev-spike:\n url: http://h:8080/graphs/spike\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.find_server_for_url("http://h:8080"), Some("dev"));
|
||||
assert_eq!(
|
||||
config.find_server_for_url("http://h:8080/graphs/other"),
|
||||
Some("dev")
|
||||
);
|
||||
// longest prefix wins
|
||||
assert_eq!(
|
||||
config.find_server_for_url("http://h:8080/graphs/spike/queries/q"),
|
||||
Some("dev-spike")
|
||||
);
|
||||
// no substring trap: a different port/host must not match
|
||||
assert_eq!(config.find_server_for_url("http://h:8080-evil/x"), None);
|
||||
assert_eq!(config.find_server_for_url("http://other:9999"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_lookup_supports_targeting() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"servers:\n intel-dev:\n url: http://127.0.0.1:8080/\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
// the --server resolution shape: bare url and graph-scoped url
|
||||
let base = config.servers["intel-dev"].url.trim_end_matches('/');
|
||||
assert_eq!(base, "http://127.0.0.1:8080");
|
||||
assert_eq!(
|
||||
format!("{base}/graphs/spike"),
|
||||
"http://127.0.0.1:8080/graphs/spike"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_env_name_uppercases_and_underscores() {
|
||||
assert_eq!(token_env_name("intel-dev"), "OMNIGRAPH_TOKEN_INTEL_DEV");
|
||||
assert_eq!(token_env_name("prod"), "OMNIGRAPH_TOKEN_PROD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_roundtrip_rotate_remove_preserving_other_sections() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("credentials");
|
||||
|
||||
rewrite_credentials_at(&path, "prod", Some("tok-1")).unwrap();
|
||||
rewrite_credentials_at(&path, "dev", Some("tok-dev")).unwrap();
|
||||
assert_eq!(
|
||||
read_credential_at(&path, "prod").unwrap().as_deref(),
|
||||
Some("tok-1")
|
||||
);
|
||||
|
||||
// rotate prod; dev preserved
|
||||
rewrite_credentials_at(&path, "prod", Some("tok-2")).unwrap();
|
||||
assert_eq!(
|
||||
read_credential_at(&path, "prod").unwrap().as_deref(),
|
||||
Some("tok-2")
|
||||
);
|
||||
assert_eq!(
|
||||
read_credential_at(&path, "dev").unwrap().as_deref(),
|
||||
Some("tok-dev")
|
||||
);
|
||||
|
||||
// remove prod; dev preserved; removal is idempotent
|
||||
rewrite_credentials_at(&path, "prod", None).unwrap();
|
||||
rewrite_credentials_at(&path, "prod", None).unwrap();
|
||||
assert_eq!(read_credential_at(&path, "prod").unwrap(), None);
|
||||
assert_eq!(
|
||||
read_credential_at(&path, "dev").unwrap().as_deref(),
|
||||
Some("tok-dev")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn credentials_written_0600_and_over_permissive_refused() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("credentials");
|
||||
rewrite_credentials_at(&path, "prod", Some("tok")).unwrap();
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600, "written {:o}", mode & 0o777);
|
||||
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let err = read_credential_at(&path, "prod").unwrap_err();
|
||||
assert!(err.to_string().contains("chmod 600"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_tilde_resolves_home_prefix() {
|
||||
let home = env::home_dir().unwrap();
|
||||
assert_eq!(expand_tilde("~"), home);
|
||||
assert_eq!(expand_tilde("~/x/y"), home.join("x/y"));
|
||||
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
|
||||
assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,357 +0,0 @@
|
|||
//! Declared CLI "planes" (RFC-010 Slice 1).
|
||||
//!
|
||||
//! Every subcommand belongs to exactly one plane. This classification is the
|
||||
//! single source of truth the wrong-plane guard consumes — and that later
|
||||
//! RFC-010 slices (the capability surface, plane-grouped help) will consume
|
||||
//! too. The `command_plane` match is **exhaustive on purpose**: adding a
|
||||
//! `Command` variant is a compile error until its plane is declared, so the
|
||||
//! surface cannot silently drift from the command set.
|
||||
//!
|
||||
//! See [docs/dev/rfc-010-cli-planes-restructure.md].
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::bail;
|
||||
|
||||
use crate::cli::{Cli, Command, QueriesCommand, SchemaCommand};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Plane {
|
||||
/// Runs against a graph, embedded **or** via `--server` (the `GraphClient`
|
||||
/// axis). The only plane on which the data-plane addressing flags
|
||||
/// (`--server`/`--graph`) apply.
|
||||
Data,
|
||||
/// Direct storage access; no server. Maintenance + local-only inspection
|
||||
/// that must work with the server down.
|
||||
Storage,
|
||||
/// Operates on a cluster directory, not a graph URI.
|
||||
Control,
|
||||
/// Touches no graph at all — session / config / local tooling.
|
||||
Session,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Plane {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Plane::Data => "data",
|
||||
Plane::Storage => "storage",
|
||||
Plane::Control => "control",
|
||||
Plane::Session => "session",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What a command *needs*, in the user-facing vocabulary (RFC-011). This is the
|
||||
/// language CLI errors and `--help` speak; `Plane` stays the internal classifier
|
||||
/// (`Capability` is derived from it, so the two cannot drift).
|
||||
///
|
||||
/// - `any` — graph-scoped data; served via a server scope, or direct against a
|
||||
/// store scope. Accepts `--server`/`--graph`.
|
||||
/// - `served` — requires a server. Accepts `--server`/`--graph`.
|
||||
/// - `direct` — storage-native; opens storage directly, never through a server.
|
||||
/// - `control` — operates on a cluster (control plane).
|
||||
/// - `local` — addresses no graph at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Capability {
|
||||
Any,
|
||||
Served,
|
||||
Direct,
|
||||
Control,
|
||||
Local,
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// A human phrase for error messages (`` `optimize` is a {…} command ``).
|
||||
pub(crate) fn describe(self) -> &'static str {
|
||||
match self {
|
||||
Capability::Any => "data",
|
||||
Capability::Served => "served",
|
||||
Capability::Direct => "direct (storage-native)",
|
||||
Capability::Control => "cluster control",
|
||||
Capability::Local => "local",
|
||||
}
|
||||
}
|
||||
|
||||
/// `--server`/`--graph` are served-graph addressing: they apply only to the
|
||||
/// capabilities that reach a graph through a server.
|
||||
fn accepts_server_addressing(self) -> bool {
|
||||
matches!(self, Capability::Any | Capability::Served)
|
||||
}
|
||||
}
|
||||
|
||||
/// The capability a subcommand needs, derived from its `Plane` (the exhaustive
|
||||
/// classifier) plus the one Data→Served refinement: `graphs` is remote-only.
|
||||
///
|
||||
/// This reflects *current enforced behavior*, so messages stay truthful:
|
||||
/// `queries`/`policy` read a cluster's applied state (`Control`).
|
||||
pub(crate) fn command_capability(cmd: &Command) -> Capability {
|
||||
if let Command::Graphs { .. } = cmd {
|
||||
return Capability::Served;
|
||||
}
|
||||
match command_plane(cmd) {
|
||||
Plane::Data => Capability::Any,
|
||||
Plane::Storage => Capability::Direct,
|
||||
Plane::Control => Capability::Control,
|
||||
Plane::Session => Capability::Local,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane a subcommand belongs to. Exhaustive — a new `Command` variant
|
||||
/// will not compile until classified. Descends into the nested enums where
|
||||
/// the plane differs per subcommand (`schema plan` is storage while `schema
|
||||
/// show`/`apply` are data; `queries`/`policy` read cluster applied state).
|
||||
pub(crate) fn command_plane(cmd: &Command) -> Plane {
|
||||
match cmd {
|
||||
Command::Query { .. }
|
||||
| Command::Mutate { .. }
|
||||
| Command::Load { .. }
|
||||
| Command::Ingest { .. }
|
||||
| Command::Branch { .. }
|
||||
| Command::Snapshot { .. }
|
||||
| Command::Export { .. }
|
||||
| Command::Commit { .. }
|
||||
| Command::Graphs { .. } => Plane::Data,
|
||||
Command::Schema {
|
||||
command: SchemaCommand::Show { .. } | SchemaCommand::Apply { .. },
|
||||
} => Plane::Data,
|
||||
Command::Schema {
|
||||
command: SchemaCommand::Plan { .. },
|
||||
} => Plane::Storage,
|
||||
// `queries` and `policy` tooling now source their inputs from a
|
||||
// cluster's applied state (`--cluster`), so they live on the control
|
||||
// plane (RFC-011 — omnigraph.yaml excised from the CLI).
|
||||
Command::Queries { .. } => Plane::Control,
|
||||
Command::Policy { .. } => Plane::Control,
|
||||
Command::Init { .. }
|
||||
| Command::Optimize { .. }
|
||||
| Command::Repair { .. }
|
||||
| Command::Cleanup { .. }
|
||||
| Command::Lint { .. } => Plane::Storage,
|
||||
Command::Cluster { .. } => Plane::Control,
|
||||
Command::Alias { .. }
|
||||
| Command::Embed(_)
|
||||
| Command::Login { .. }
|
||||
| Command::Logout { .. }
|
||||
| Command::Profile { .. }
|
||||
| Command::Version => Plane::Session,
|
||||
}
|
||||
}
|
||||
|
||||
/// User-facing label for a subcommand (descends one level for the nested
|
||||
/// families so messages read `schema plan`, `queries validate`, etc.).
|
||||
pub(crate) fn command_label(cmd: &Command) -> &'static str {
|
||||
match cmd {
|
||||
Command::Version => "version",
|
||||
Command::Login { .. } => "login",
|
||||
Command::Logout { .. } => "logout",
|
||||
Command::Profile { .. } => "profile",
|
||||
Command::Embed(_) => "embed",
|
||||
Command::Init { .. } => "init",
|
||||
Command::Load { .. } => "load",
|
||||
Command::Ingest { .. } => "ingest",
|
||||
Command::Branch { .. } => "branch",
|
||||
Command::Schema { command } => match command {
|
||||
SchemaCommand::Plan { .. } => "schema plan",
|
||||
SchemaCommand::Apply { .. } => "schema apply",
|
||||
SchemaCommand::Show { .. } => "schema show",
|
||||
},
|
||||
Command::Lint { .. } => "lint",
|
||||
Command::Queries { command } => match command {
|
||||
QueriesCommand::Validate { .. } => "queries validate",
|
||||
QueriesCommand::List { .. } => "queries list",
|
||||
},
|
||||
Command::Snapshot { .. } => "snapshot",
|
||||
Command::Export { .. } => "export",
|
||||
Command::Commit { .. } => "commit",
|
||||
Command::Query { .. } => "query",
|
||||
Command::Mutate { .. } => "mutate",
|
||||
Command::Alias { .. } => "alias",
|
||||
Command::Policy { .. } => "policy",
|
||||
Command::Optimize { .. } => "optimize",
|
||||
Command::Repair { .. } => "repair",
|
||||
Command::Cleanup { .. } => "cleanup",
|
||||
Command::Cluster { .. } => "cluster",
|
||||
Command::Graphs { .. } => "graphs",
|
||||
}
|
||||
}
|
||||
|
||||
/// The verbs that consume a cluster scope. Maintenance/lint select a graph with
|
||||
/// `--cluster <root> --graph <id>`; policy/queries inspect the cluster's
|
||||
/// applied control-plane state and may optionally use `--graph` to select one
|
||||
/// bundle/registry. `init` is storage-plane too but *creates* a graph (cluster
|
||||
/// graphs are born from `cluster apply`, not `init`), and `schema plan` takes a
|
||||
/// positional URI, so the guard rejects `--cluster`/`--graph` there rather than
|
||||
/// silently dropping the flag.
|
||||
pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
Command::Optimize { .. }
|
||||
| Command::Repair { .. }
|
||||
| Command::Cleanup { .. }
|
||||
// `lint` can type-check a `.gq` against a cluster graph's schema
|
||||
// (RFC-011): `--cluster <dir> --graph <id>`.
|
||||
| Command::Lint { .. }
|
||||
// The policy/queries tooling addresses a cluster's applied state
|
||||
// (RFC-011): `--cluster <dir>` selects the cluster, `--graph <id>`
|
||||
// picks a graph's bundle/registry within it.
|
||||
| Command::Policy { .. }
|
||||
| Command::Queries { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Reject a scope-addressing flag (`--server`/`--cluster`/`--graph`) on a verb
|
||||
/// that cannot consume it, rather than silently dropping it (the old behavior:
|
||||
/// e.g. `optimize --server prod` dropped `--server` and failed later with an
|
||||
/// unrelated message). `alias` gets an extra guard because its binding owns all
|
||||
/// addressing and several ignored globals sit outside this three-flag guard.
|
||||
/// Each flag has a distinct valid surface:
|
||||
/// - `--server` → served-graph scopes (`any`/`served`);
|
||||
/// - `--cluster` → cluster-scoped direct/control verbs;
|
||||
/// - `--graph` → any multi-graph scope: a served scope *or* a cluster one.
|
||||
/// RFC-010 Slice 1, generalized for RFC-011 cluster addressing.
|
||||
pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> {
|
||||
if let Command::Alias { .. } = &cli.command {
|
||||
let mut flags = Vec::new();
|
||||
if cli.server.is_some() {
|
||||
flags.push("--server");
|
||||
}
|
||||
if cli.graph.is_some() {
|
||||
flags.push("--graph");
|
||||
}
|
||||
if cli.store.is_some() {
|
||||
flags.push("--store");
|
||||
}
|
||||
if cli.cluster.is_some() {
|
||||
flags.push("--cluster");
|
||||
}
|
||||
if cli.profile.is_some() {
|
||||
flags.push("--profile");
|
||||
}
|
||||
if cli.as_actor.is_some() {
|
||||
flags.push("--as");
|
||||
}
|
||||
if !flags.is_empty() {
|
||||
bail!(
|
||||
"`alias` uses the server, graph, and stored query declared in \
|
||||
`aliases.<name>` in ~/.omnigraph/config.yaml; remove global scope \
|
||||
flag(s): {}",
|
||||
flags.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let capability = command_capability(&cli.command);
|
||||
let label = command_label(&cli.command);
|
||||
let cluster_ok = accepts_cluster_addressing(&cli.command);
|
||||
|
||||
if cli.server.is_some() && !capability.accepts_server_addressing() {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --server addresses a served graph and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
}
|
||||
if cli.cluster.is_some() && !cluster_ok {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --cluster addresses a cluster-scoped command \
|
||||
and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
}
|
||||
if cli.graph.is_some() && !(capability.accepts_server_addressing() || cluster_ok) {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --graph selects a graph within a server or cluster \
|
||||
scope and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The "what to do instead" tail for a wrong-address error, by capability.
|
||||
/// Includes its own leading space when non-empty so the caller appends it
|
||||
/// directly — an empty tail (the served-addressing capabilities, which only
|
||||
/// reach this fn for a misplaced `--cluster`/`--graph`) leaves no trailing space.
|
||||
fn remediation(capability: Capability, cmd: &Command) -> &'static str {
|
||||
match capability {
|
||||
Capability::Direct => match cmd {
|
||||
Command::Init { .. } => " Pass a storage URI.",
|
||||
Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } => {
|
||||
" Pass a storage URI, or --cluster <dir> --graph <id>."
|
||||
}
|
||||
_ => " Pass a storage URI.",
|
||||
},
|
||||
Capability::Control => match cmd {
|
||||
Command::Cluster { .. } => {
|
||||
" It operates on a cluster config directory (pass --config <dir>)."
|
||||
}
|
||||
Command::Policy { .. } | Command::Queries { .. } => {
|
||||
" It operates on a cluster (pass --cluster <dir|uri>, or select a cluster profile)."
|
||||
}
|
||||
_ => " It operates on a cluster.",
|
||||
},
|
||||
Capability::Local => " It does not address a graph.",
|
||||
Capability::Any | Capability::Served => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn server_addressing_allowed_exactly_on_any_and_served() {
|
||||
// The behavior-preservation contract: `--server`/`--graph` apply to the
|
||||
// served-graph capabilities (`any`, `served`) and nothing else. This is
|
||||
// the old "Data plane only" allow set, re-expressed — graphs (the one
|
||||
// Data→Served verb) was already allowed.
|
||||
assert!(Capability::Any.accepts_server_addressing());
|
||||
assert!(Capability::Served.accepts_server_addressing());
|
||||
assert!(!Capability::Direct.accepts_server_addressing());
|
||||
assert!(!Capability::Control.accepts_server_addressing());
|
||||
assert!(!Capability::Local.accepts_server_addressing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_capability_classifies_representative_verbs() {
|
||||
let cap = |args: &[&str]| {
|
||||
command_capability(&Cli::try_parse_from(args).unwrap().command)
|
||||
};
|
||||
// The one Data→Served refinement — if the `graphs` guard were deleted,
|
||||
// every other assertion here would still pass.
|
||||
assert_eq!(cap(&["omnigraph", "graphs", "list"]), Capability::Served);
|
||||
assert_eq!(cap(&["omnigraph", "alias", "who"]), Capability::Local);
|
||||
assert_eq!(cap(&["omnigraph", "optimize", "graph.omni"]), Capability::Direct);
|
||||
assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct);
|
||||
assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control);
|
||||
assert_eq!(cap(&["omnigraph", "version"]), Capability::Local);
|
||||
// `queries`/`policy` tooling reads cluster state now (control plane).
|
||||
assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Control);
|
||||
assert_eq!(
|
||||
cap(&["omnigraph", "policy", "validate"]),
|
||||
Capability::Control
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_capability_describes_distinctly() {
|
||||
let phrases = [
|
||||
Capability::Any.describe(),
|
||||
Capability::Served.describe(),
|
||||
Capability::Direct.describe(),
|
||||
Capability::Control.describe(),
|
||||
Capability::Local.describe(),
|
||||
];
|
||||
for (i, a) in phrases.iter().enumerate() {
|
||||
assert!(!a.is_empty());
|
||||
for b in &phrases[i + 1..] {
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,9 @@
|
|||
use clap::ValueEnum;
|
||||
use color_eyre::eyre::Result;
|
||||
use omnigraph_server::ReadOutputFormat;
|
||||
use omnigraph_server::api::ReadOutput;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use omnigraph_server::config::TableCellLayout;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Output rendering format for read-shaped commands (`read`/`query`/`alias`).
|
||||
/// A CLI presentation concern — lives here, not in the server.
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReadOutputFormat {
|
||||
#[default]
|
||||
Table,
|
||||
Kv,
|
||||
Csv,
|
||||
Jsonl,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// How an over-wide table cell is laid out when rendering `--format table`.
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TableCellLayout {
|
||||
#[default]
|
||||
Truncate,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
pub struct ReadRenderOptions {
|
||||
pub max_column_width: usize,
|
||||
pub cell_layout: TableCellLayout,
|
||||
|
|
|
|||
|
|
@ -1,529 +0,0 @@
|
|||
//! RFC-011 Slice A scope resolution.
|
||||
//!
|
||||
//! Translates the new scope inputs (`--profile` / `--store` / operator-config
|
||||
//! `profiles`/`clusters`/`defaults`) into the SAME effective addressing tuple
|
||||
//! the existing `GraphClient` factories (`client.rs`) and the maintenance
|
||||
//! resolver (`helpers::resolve_storage_uri`) already consume. This is a
|
||||
//! translation layer that sits *in front* of those resolvers — it is purely
|
||||
//! additive: an explicit legacy address (`--uri`/`--target`/`--server`/
|
||||
//! `--store`) wins and reproduces today's behavior exactly, so existing
|
||||
//! invocations are unaffected.
|
||||
//!
|
||||
//! The access path (served vs direct) is never chosen here; it falls out of the
|
||||
//! scope's binding × the verb's capability. The capability→scope check rejects
|
||||
//! mismatches (e.g. a server scope on a maintenance verb) only on the *new*
|
||||
//! resolution paths.
|
||||
|
||||
use std::env;
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::{bail, eyre};
|
||||
|
||||
use crate::operator::{OperatorConfig, ScopeBinding};
|
||||
use crate::planes::Capability;
|
||||
|
||||
pub(crate) const PROFILE_ENV: &str = "OMNIGRAPH_PROFILE";
|
||||
|
||||
/// The effective addressing a command should use, in the terms the existing
|
||||
/// resolvers consume. Data/served verbs read `server`/`graph`/`uri`/`target`;
|
||||
/// maintenance verbs read `cluster`/`cluster_graph`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ResolvedScope {
|
||||
pub(crate) server: Option<String>,
|
||||
pub(crate) graph: Option<String>,
|
||||
pub(crate) uri: Option<String>,
|
||||
pub(crate) cluster: Option<String>,
|
||||
pub(crate) cluster_graph: Option<String>,
|
||||
}
|
||||
|
||||
/// The raw addressing inputs for one command: the global scope flags plus the
|
||||
/// command's own positional URI.
|
||||
pub(crate) struct ScopeFlags<'a> {
|
||||
pub(crate) profile: Option<&'a str>,
|
||||
pub(crate) store: Option<&'a str>,
|
||||
pub(crate) server: Option<&'a str>,
|
||||
pub(crate) cluster: Option<&'a str>,
|
||||
pub(crate) graph: Option<&'a str>,
|
||||
pub(crate) uri: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the scope for a command with `capability`. Precedence (RFC-011):
|
||||
/// 1. explicit primitive address (`uri`/`--server`/`--store`) → passthrough;
|
||||
/// 2. `--profile` / `OMNIGRAPH_PROFILE`;
|
||||
/// 3. flat `defaults.server` + `defaults.default_graph`;
|
||||
/// 4. nothing — downstream behaves as today.
|
||||
pub(crate) fn resolve_scope(
|
||||
op: &OperatorConfig,
|
||||
capability: Capability,
|
||||
flags: ScopeFlags<'_>,
|
||||
) -> Result<ResolvedScope> {
|
||||
// At most one explicit scope primitive may address a command — a positional
|
||||
// URI, `--store`, `--server`, or `--cluster` are mutually exclusive ways to
|
||||
// name the graph. Combining them is a contradiction, not a silent precedence.
|
||||
let primitives: Vec<&str> = [
|
||||
flags.uri.as_deref().map(|_| "a positional URI"),
|
||||
flags.store.map(|_| "--store"),
|
||||
flags.server.map(|_| "--server"),
|
||||
flags.cluster.map(|_| "--cluster"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
if primitives.len() > 1 {
|
||||
bail!(
|
||||
"{} are mutually exclusive — pick one way to address the graph",
|
||||
primitives.join(" and ")
|
||||
);
|
||||
}
|
||||
|
||||
// 1a. `--cluster` is the cluster scope primitive (maintenance): resolve its
|
||||
// root + select the graph with `--graph`.
|
||||
if let Some(cluster) = flags.cluster {
|
||||
return scope_from_binding(
|
||||
op,
|
||||
capability,
|
||||
ScopeBinding::Cluster(cluster.to_string()),
|
||||
flags.graph.map(str::to_string),
|
||||
"--cluster",
|
||||
);
|
||||
}
|
||||
|
||||
// 1b. Any other explicit address wins; reproduce today's behavior untouched.
|
||||
// `--store` is an explicit store URI — fold it into `uri`.
|
||||
if flags.uri.is_some() || flags.server.is_some() || flags.store.is_some() {
|
||||
// `--graph` selects within a multi-graph scope; a bare positional URI /
|
||||
// `--store` is already a single graph, so a stray `--graph` is an error
|
||||
// rather than a silently-dropped flag.
|
||||
if flags.graph.is_some() && flags.server.is_none() {
|
||||
bail!(
|
||||
"--graph selects a graph within a server or cluster scope; a positional \
|
||||
URI / --store is already a single graph"
|
||||
);
|
||||
}
|
||||
return Ok(ResolvedScope {
|
||||
server: flags.server.map(str::to_string),
|
||||
graph: flags.graph.map(str::to_string),
|
||||
uri: flags.store.map(str::to_string).or(flags.uri),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// 2. A named profile (flag, else env).
|
||||
let profile_name = flags
|
||||
.profile
|
||||
.map(str::to_string)
|
||||
.or_else(|| env::var(PROFILE_ENV).ok().filter(|s| !s.is_empty()));
|
||||
if let Some(name) = profile_name {
|
||||
let profile = op.profile(&name).ok_or_else(|| {
|
||||
eyre!("unknown profile '{name}' (not defined under `profiles:` in operator config)")
|
||||
})?;
|
||||
let binding = profile.binding(&name)?;
|
||||
let graph = flags
|
||||
.graph
|
||||
.map(str::to_string)
|
||||
.or_else(|| profile.default_graph.clone());
|
||||
return scope_from_binding(op, capability, binding, graph, &format!("profile '{name}'"));
|
||||
}
|
||||
|
||||
// 3. Flat default server scope.
|
||||
if let Some(server) = op.default_server() {
|
||||
let graph = flags
|
||||
.graph
|
||||
.map(str::to_string)
|
||||
.or_else(|| op.default_graph().map(str::to_string));
|
||||
return scope_from_binding(
|
||||
op,
|
||||
capability,
|
||||
ScopeBinding::Server(server.to_string()),
|
||||
graph,
|
||||
"operator defaults",
|
||||
);
|
||||
}
|
||||
|
||||
// 3b. Flat default store scope — the zero-flag local-dev default (RFC-011).
|
||||
// Mutually exclusive with `defaults.server` (enforced at config load).
|
||||
if let Some(store) = op.default_store() {
|
||||
return scope_from_binding(
|
||||
op,
|
||||
capability,
|
||||
ScopeBinding::Store(store.to_string()),
|
||||
flags.graph.map(str::to_string),
|
||||
"operator defaults",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Nothing resolved — leave the tuple empty; downstream falls through to
|
||||
// today's behavior (legacy `cli.graph` default or a no-address error).
|
||||
Ok(ResolvedScope::default())
|
||||
}
|
||||
|
||||
/// Map a resolved binding to the effective tuple, enforcing scope × capability
|
||||
/// capability (RFC-011): a server scope is served (data only); a cluster scope
|
||||
/// is privileged direct (maintenance/control only); a store scope is direct
|
||||
/// (either).
|
||||
fn scope_from_binding(
|
||||
op: &OperatorConfig,
|
||||
capability: Capability,
|
||||
binding: ScopeBinding,
|
||||
graph: Option<String>,
|
||||
source: &str,
|
||||
) -> Result<ResolvedScope> {
|
||||
match binding {
|
||||
ScopeBinding::Server(server) => {
|
||||
if capability == Capability::Direct {
|
||||
bail!(
|
||||
"this command needs direct storage access, but {source} resolves a \
|
||||
server scope; name storage explicitly with --store <uri> (or \
|
||||
--cluster <dir> --graph <id> for a managed graph)"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedScope {
|
||||
server: Some(server),
|
||||
graph,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
ScopeBinding::Cluster(cluster) => {
|
||||
if capability == Capability::Any {
|
||||
bail!(
|
||||
"{source} resolves a cluster scope, which is not valid for graph data \
|
||||
commands; run data commands through a server, or use --store <uri> \
|
||||
for ad-hoc direct access"
|
||||
);
|
||||
}
|
||||
// A cluster value is a config name (resolved against `clusters:`)
|
||||
// or a literal root: an `s3://`/`file://` URI or a local cluster
|
||||
// directory. Only a configured name is rewritten; anything else is
|
||||
// passed through to the cluster-state resolver verbatim, so a bare
|
||||
// directory path keeps working as it did for per-command `--cluster`.
|
||||
let root = op
|
||||
.cluster_root(&cluster)
|
||||
.map(str::to_string)
|
||||
.unwrap_or(cluster);
|
||||
// A cluster holds many graphs; maintenance addresses one at a time.
|
||||
// When no `--graph`/`default_graph` is given, leave `cluster_graph`
|
||||
// empty and defer to the async storage-URI resolver (RFC-011 D7),
|
||||
// which enumerates the catalog: auto-use a sole graph, else error
|
||||
// and list the candidates.
|
||||
Ok(ResolvedScope {
|
||||
cluster: Some(root),
|
||||
cluster_graph: graph,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
ScopeBinding::Store(uri) => {
|
||||
if graph.is_some() {
|
||||
bail!(
|
||||
"--graph does not apply to a store scope ({source}): a store is already \
|
||||
a single graph"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedScope {
|
||||
uri: Some(uri),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(yaml: &str) -> OperatorConfig {
|
||||
serde_yaml::from_str(yaml).unwrap()
|
||||
}
|
||||
|
||||
fn flags<'a>() -> ScopeFlags<'a> {
|
||||
ScopeFlags {
|
||||
profile: None,
|
||||
store: None,
|
||||
server: None,
|
||||
cluster: None,
|
||||
graph: None,
|
||||
uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_legacy_address_wins_unchanged() {
|
||||
let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n");
|
||||
// A positional URI given → profile/defaults are ignored entirely.
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
uri: Some("graph.omni".into()),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.uri.as_deref(), Some("graph.omni"));
|
||||
assert_eq!(scope.server, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_flag_folds_into_uri_and_rejects_graph() {
|
||||
let op = OperatorConfig::default();
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
store: Some("s3://b/g.omni"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.uri.as_deref(), Some("s3://b/g.omni"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_primitives_are_mutually_exclusive() {
|
||||
let op = OperatorConfig::default();
|
||||
for flags in [
|
||||
ScopeFlags {
|
||||
store: Some("s3://b/g.omni"),
|
||||
uri: Some("file://other.omni".into()),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
store: Some("s3://b/g.omni"),
|
||||
server: Some("prod"),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
cluster: Some("./brain"),
|
||||
uri: Some("file://other.omni".into()),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
cluster: Some("./brain"),
|
||||
server: Some("prod"),
|
||||
..flags()
|
||||
},
|
||||
] {
|
||||
let err = resolve_scope(&op, Capability::Direct, flags)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("mutually exclusive"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_flag_resolves_root_and_graph_for_maintenance() {
|
||||
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
cluster: Some("brain"),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_flag_accepts_a_literal_root_uri() {
|
||||
let op = OperatorConfig::default();
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
cluster: Some("s3://bucket/clusters/brain"),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://bucket/clusters/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_scope_without_a_graph_defers_to_catalog_enumeration() {
|
||||
// RFC-011 D7: with no `--graph`/`default_graph`, resolution no longer
|
||||
// bails here — it resolves the cluster root and leaves `cluster_graph`
|
||||
// empty, deferring to the async storage-URI resolver (which enumerates
|
||||
// the catalog: auto-use a sole graph, else error listing candidates).
|
||||
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
cluster: Some("brain"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_on_a_bare_store_or_uri_is_rejected() {
|
||||
let op = OperatorConfig::default();
|
||||
for flags in [
|
||||
ScopeFlags {
|
||||
uri: Some("graph.omni".into()),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
store: Some("s3://b/g.omni"),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
] {
|
||||
let err = resolve_scope(&op, Capability::Any, flags)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("already a single graph"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_default_store_drives_local_verbs() {
|
||||
// RFC-011: `defaults.store` is the zero-flag local default — no flags,
|
||||
// no profile → the store URI resolves as the (single-graph) store scope.
|
||||
let op = cfg("defaults:\n store: file:///tmp/dev.omni\n");
|
||||
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
|
||||
assert_eq!(scope.uri.as_deref(), Some("file:///tmp/dev.omni"));
|
||||
assert_eq!(scope.server, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_default_store_rejects_graph() {
|
||||
// A store is already a single graph, so `--graph` against a default
|
||||
// store is a loud error.
|
||||
let op = cfg("defaults:\n store: file:///tmp/dev.omni\n");
|
||||
let err = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("does not apply to a store scope"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_default_server_drives_data_verbs() {
|
||||
let op = cfg("defaults:\n server: prod\n default_graph: knowledge\nservers:\n prod:\n url: https://x\n");
|
||||
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
|
||||
assert_eq!(scope.server.as_deref(), Some("prod"));
|
||||
assert_eq!(scope.graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_server_scope_with_graph_override() {
|
||||
let op = cfg(
|
||||
"servers:\n staging:\n url: https://s\nprofiles:\n staging:\n server: staging\n default_graph: knowledge\n",
|
||||
);
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
profile: Some("staging"),
|
||||
graph: Some("archive"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.server.as_deref(), Some("staging"));
|
||||
assert_eq!(scope.graph.as_deref(), Some("archive")); // flag beats profile default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cluster_scope_resolves_root_for_maintenance() {
|
||||
let op = cfg(
|
||||
"clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n",
|
||||
);
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
profile: Some("admin"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cluster_scope_with_graph_override() {
|
||||
// The deferral closed by this slice: a `--graph` flag overrides a
|
||||
// profile cluster's default_graph, exactly as it does for a server scope.
|
||||
let op = cfg(
|
||||
"clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n",
|
||||
);
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
profile: Some("admin"),
|
||||
graph: Some("archive"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("archive")); // flag beats profile default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_scope_on_maintenance_verb_errors() {
|
||||
let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n");
|
||||
let err = resolve_scope(&op, Capability::Direct, flags()).unwrap_err().to_string();
|
||||
assert!(err.contains("direct storage access"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_scope_on_data_verb_errors() {
|
||||
let op = cfg(
|
||||
"clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n",
|
||||
);
|
||||
let err = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
profile: Some("admin"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("not valid for graph data commands"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_is_a_loud_error() {
|
||||
let op = OperatorConfig::default();
|
||||
let err = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
profile: Some("nope"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("unknown profile 'nope'"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_address_resolves_empty_for_legacy_fallthrough() {
|
||||
let op = OperatorConfig::default();
|
||||
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
|
||||
assert_eq!(scope, ResolvedScope::default());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,623 +0,0 @@
|
|||
//! Cluster lifecycle compositions over the spawned binary (recovery, drift, convergence).
|
||||
//! Moved verbatim from tests/cli.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use omnigraph::db::Omnigraph;
|
||||
use tempfile::tempdir;
|
||||
|
||||
mod support;
|
||||
|
||||
use support::*;
|
||||
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_lifecycle_import_apply_status_refresh_converges() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
init_cluster_derived_graph(temp.path());
|
||||
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
assert_eq!(import["state_observations"]["state_revision"], 1);
|
||||
|
||||
let plan = cluster_json(temp.path(), "plan");
|
||||
let changes = plan["changes"].as_array().unwrap();
|
||||
assert_eq!(changes.len(), 3, "{plan}");
|
||||
let disposition_of = |resource: &str| {
|
||||
changes
|
||||
.iter()
|
||||
.find(|change| change["resource"] == resource)
|
||||
.unwrap_or_else(|| panic!("missing change for {resource}: {plan}"))["disposition"]
|
||||
.clone()
|
||||
};
|
||||
assert_eq!(disposition_of("graph.knowledge"), "derived");
|
||||
assert_eq!(disposition_of("query.knowledge.find_person"), "applied");
|
||||
assert_eq!(disposition_of("policy.base"), "applied");
|
||||
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["ok"], true, "{apply}");
|
||||
assert_eq!(apply["applied_count"], 2, "{apply}");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
|
||||
let status = cluster_json(temp.path(), "status");
|
||||
assert_eq!(
|
||||
status["resource_statuses"]["query.knowledge.find_person"]["status"],
|
||||
"applied"
|
||||
);
|
||||
assert_eq!(status["resource_statuses"]["policy.base"]["status"], "applied");
|
||||
assert!(
|
||||
status["state_observations"]["applied_config_digest"].is_string(),
|
||||
"converged apply must record the applied config digest: {status}"
|
||||
);
|
||||
|
||||
// Refresh re-observes the live graph; it must not undo apply's work.
|
||||
let refresh = cluster_json(temp.path(), "refresh");
|
||||
assert_eq!(refresh["ok"], true, "{refresh}");
|
||||
let replan = cluster_json(temp.path(), "plan");
|
||||
assert!(
|
||||
replan["changes"].as_array().unwrap().is_empty(),
|
||||
"refresh after a converged apply must not re-open the plan: {replan}"
|
||||
);
|
||||
|
||||
// A query edit round-trips: plan update -> apply -> converged again.
|
||||
fs::write(
|
||||
temp.path().join("people.gq"),
|
||||
r#"
|
||||
query find_person($name: String) {
|
||||
match { $p: Person { name: $name } }
|
||||
return { $p.name }
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let apply_edit = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply_edit["applied_count"], 1, "{apply_edit}");
|
||||
assert_eq!(apply_edit["converged"], true, "{apply_edit}");
|
||||
|
||||
let final_apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(final_apply["state_written"], false, "{final_apply}");
|
||||
assert!(final_apply["changes"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_schema_change_applied_by_cluster() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
init_cluster_derived_graph(temp.path());
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
|
||||
// Additive schema change: Stage 4B applies it from the cluster — no
|
||||
// manual schema apply, no refresh round-trip.
|
||||
fs::write(
|
||||
temp.path().join("people.pg"),
|
||||
r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
bio: String?
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Plan previews the real migration steps (RFC-004 §D7).
|
||||
let plan = cluster_json(temp.path(), "plan");
|
||||
let schema_change = change_for(&plan, "schema.knowledge");
|
||||
assert_eq!(schema_change["disposition"], "applied", "{plan}");
|
||||
let migration = &schema_change["migration"];
|
||||
assert_eq!(migration["supported"], true, "{plan}");
|
||||
assert!(
|
||||
migration["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|step| step["kind"] == "add_property"),
|
||||
"{plan}"
|
||||
);
|
||||
|
||||
let evolve = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(evolve["ok"], true, "{evolve}");
|
||||
assert_eq!(evolve["converged"], true, "{evolve}");
|
||||
assert_eq!(change_for(&evolve, "schema.knowledge")["disposition"], "applied");
|
||||
|
||||
// The live graph carries the new schema; the plan is empty.
|
||||
let schema_show = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("show")
|
||||
.arg(temp.path().join("graphs/knowledge.omni")),
|
||||
);
|
||||
assert!(stdout_string(&schema_show).contains("bio"), "live schema updated");
|
||||
let replan = cluster_json(temp.path(), "plan");
|
||||
assert!(
|
||||
replan["changes"].as_array().unwrap().is_empty(),
|
||||
"one cluster apply converges a schema change: {replan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_force_unlock_unblocks_apply() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
write_cluster_applyable_state(temp.path());
|
||||
write_cluster_lock(temp.path(), "stuck-lock", "apply");
|
||||
|
||||
let refused = parse_stdout_json(&output_failure(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("apply")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(refused["ok"], false);
|
||||
|
||||
let unlocked = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("force-unlock")
|
||||
.arg("stuck-lock")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(unlocked["lock_removed"], true, "{unlocked}");
|
||||
|
||||
let retried = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(retried["ok"], true, "{retried}");
|
||||
assert_eq!(retried["converged"], true, "{retried}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_lost_state_reimport_recovers_catalog() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
init_cluster_derived_graph(temp.path());
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
|
||||
let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let blob = temp
|
||||
.path()
|
||||
.join("__cluster/resources/query/knowledge/find_person")
|
||||
.join(format!("{query_digest}.gq"));
|
||||
let blob_content = fs::read_to_string(&blob).unwrap();
|
||||
|
||||
// Disaster: the state ledger is lost.
|
||||
fs::remove_file(temp.path().join("__cluster/state.json")).unwrap();
|
||||
|
||||
let reimport = cluster_json(temp.path(), "import");
|
||||
assert_eq!(reimport["ok"], true, "{reimport}");
|
||||
assert_eq!(reimport["state_observations"]["state_revision"], 1);
|
||||
// Import observes graph/schema only; query/policy digests are not invented.
|
||||
assert!(
|
||||
reimport["resource_digests"]
|
||||
.get("query.knowledge.find_person")
|
||||
.is_none(),
|
||||
"{reimport}"
|
||||
);
|
||||
|
||||
let plan = cluster_json(temp.path(), "plan");
|
||||
assert_eq!(
|
||||
change_for(&plan, "query.knowledge.find_person")["disposition"],
|
||||
"applied"
|
||||
);
|
||||
assert_eq!(change_for(&plan, "policy.base")["disposition"], "applied");
|
||||
|
||||
let reapply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(reapply["ok"], true, "{reapply}");
|
||||
assert_eq!(reapply["converged"], true, "{reapply}");
|
||||
assert!(
|
||||
reapply["state_observations"]["applied_config_digest"].is_string(),
|
||||
"{reapply}"
|
||||
);
|
||||
// The catalog blob was reused, not rewritten with different content.
|
||||
assert_eq!(fs::read_to_string(&blob).unwrap(), blob_content);
|
||||
|
||||
let replan = cluster_json(temp.path(), "plan");
|
||||
assert!(replan["changes"].as_array().unwrap().is_empty(), "{replan}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_out_of_band_schema_drift_then_apply_converges_it() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
init_cluster_derived_graph(temp.path());
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
|
||||
// Out-of-band: the live graph evolves while cluster.yaml stays put. RFC-011
|
||||
// D10 makes the CLI `schema apply` refuse a cluster-managed graph, so this
|
||||
// simulates a true bypass — a direct engine apply against the storage root,
|
||||
// exactly the drift the control plane must still detect and converge.
|
||||
let people_v2 = r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
bio: String?
|
||||
}
|
||||
"#;
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let db = Omnigraph::open(
|
||||
temp.path()
|
||||
.join("graphs/knowledge.omni")
|
||||
.to_string_lossy()
|
||||
.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.apply_schema(people_v2).await.unwrap();
|
||||
});
|
||||
|
||||
// Drift is visible...
|
||||
let refresh = cluster_json(temp.path(), "refresh");
|
||||
assert_eq!(
|
||||
refresh["resource_statuses"]["schema.knowledge"]["status"],
|
||||
"drifted"
|
||||
);
|
||||
// ...the plan proposes converging back to desired, with a migration
|
||||
// preview (a soft drop of the out-of-band field)...
|
||||
let plan = cluster_json(temp.path(), "plan");
|
||||
let schema_change = change_for(&plan, "schema.knowledge");
|
||||
assert_eq!(schema_change["disposition"], "applied", "{plan}");
|
||||
assert!(
|
||||
schema_change["migration"]["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|step| step["kind"] == "drop_property" && step["mode"] == "soft"),
|
||||
"{plan}"
|
||||
);
|
||||
// ...and apply converges the live schema back (axiom 8: drift correction
|
||||
// is gated like any change; a soft migration is the recoverable tier).
|
||||
let converge = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(converge["ok"], true, "{converge}");
|
||||
assert_eq!(converge["converged"], true, "{converge}");
|
||||
let schema_show = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("show")
|
||||
.arg(temp.path().join("graphs/knowledge.omni")),
|
||||
);
|
||||
assert!(
|
||||
!stdout_string(&schema_show).contains("bio"),
|
||||
"out-of-band field soft-dropped back to desired"
|
||||
);
|
||||
let replan = cluster_json(temp.path(), "plan");
|
||||
assert!(replan["changes"].as_array().unwrap().is_empty(), "{replan}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_graph_root_destruction_drifts_then_apply_recreates_empty_graph() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
init_cluster_derived_graph(temp.path());
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
fs::remove_dir_all(temp.path().join("graphs/knowledge.omni")).unwrap();
|
||||
|
||||
// Missing root is drift, not an error.
|
||||
let refresh = cluster_json(temp.path(), "refresh");
|
||||
assert_eq!(refresh["ok"], true, "{refresh}");
|
||||
assert_eq!(
|
||||
refresh["resource_statuses"]["graph.knowledge"]["status"],
|
||||
"drifted"
|
||||
);
|
||||
assert!(
|
||||
refresh["resource_statuses"]["graph.knowledge"]["conditions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|condition| condition == "graph_missing"),
|
||||
"{refresh}"
|
||||
);
|
||||
// Graph/schema digests removed; query/policy digests preserved.
|
||||
assert!(refresh["resource_digests"].get("graph.knowledge").is_none());
|
||||
assert!(refresh["resource_digests"].get("schema.knowledge").is_none());
|
||||
assert!(
|
||||
refresh["resource_digests"]
|
||||
.get("query.knowledge.find_person")
|
||||
.is_some(),
|
||||
"{refresh}"
|
||||
);
|
||||
|
||||
let plan = cluster_json(temp.path(), "plan");
|
||||
assert_eq!(change_for(&plan, "graph.knowledge")["operation"], "create");
|
||||
// Stage 4A: the re-create is executable and the plan says so — nothing
|
||||
// hidden about converging a destroyed root back to an EMPTY graph (the
|
||||
// data was already lost; this is declarative convergence, RFC-004 §D1).
|
||||
assert_eq!(change_for(&plan, "graph.knowledge")["disposition"], "applied");
|
||||
assert_eq!(change_for(&plan, "schema.knowledge")["disposition"], "applied");
|
||||
// Converged-then-destroyed: query/policy are already in state at the
|
||||
// desired digests, so they are not changes at all.
|
||||
assert_eq!(plan["changes"].as_array().unwrap().len(), 2, "{plan}");
|
||||
|
||||
let recreate = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(recreate["ok"], true, "{recreate}");
|
||||
assert_eq!(recreate["converged"], true, "{recreate}");
|
||||
// The empty graph is back on disk; catalog state survived throughout.
|
||||
assert!(temp.path().join("graphs/knowledge.omni").exists());
|
||||
let state: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(temp.path().join("__cluster/state.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state["applied_revision"]["resources"]["query.knowledge.find_person"]["digest"],
|
||||
query_digest
|
||||
);
|
||||
assert!(
|
||||
temp.path()
|
||||
.join("__cluster/resources/query/knowledge/find_person")
|
||||
.join(format!("{query_digest}.gq"))
|
||||
.exists()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_multi_graph_mixed_dispositions_then_approve_and_converge() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_multi_graph_cluster_fixture(temp.path());
|
||||
// No manual init: Stage 4A creates both graphs.
|
||||
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["ok"], true, "{apply}");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
assert_eq!(change_for(&apply, "graph.knowledge")["disposition"], "applied");
|
||||
assert_eq!(
|
||||
change_for(&apply, "graph.engineering")["disposition"],
|
||||
"applied"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&apply, "query.engineering.find_service")["disposition"],
|
||||
"applied"
|
||||
);
|
||||
// The graph-spanning and cluster-scoped policies ride the same run.
|
||||
assert_eq!(change_for(&apply, "policy.shared")["disposition"], "applied");
|
||||
assert_eq!(
|
||||
change_for(&apply, "policy.cluster_wide")["disposition"],
|
||||
"applied"
|
||||
);
|
||||
assert!(temp.path().join("graphs/knowledge.omni").exists());
|
||||
assert!(temp.path().join("graphs/engineering.omni").exists());
|
||||
|
||||
// Mixed run: a graph REMOVAL (4C territory — deferred) gates its query
|
||||
// delete (blocked), while a knowledge query update is independent
|
||||
// (applied) and re-derives its composite. All four dispositions at once.
|
||||
fs::write(
|
||||
temp.path().join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
metadata:
|
||||
name: company-brain
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
policies:
|
||||
shared:
|
||||
file: ./shared.policy.yaml
|
||||
applies_to: [knowledge]
|
||||
cluster_wide:
|
||||
file: ./cluster_wide.policy.yaml
|
||||
applies_to: [cluster]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("people.gq"),
|
||||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mixed = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(mixed["ok"], true, "{mixed}");
|
||||
assert_eq!(mixed["converged"], false, "{mixed}");
|
||||
// Stage 4C: deletes are gated on a digest-bound approval, one gate per
|
||||
// subtree (the graph-level approval carries schema + queries).
|
||||
assert_eq!(
|
||||
change_for(&mixed, "graph.engineering")["disposition"],
|
||||
"blocked"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "graph.engineering")["reason"],
|
||||
"approval_required"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "schema.engineering")["reason"],
|
||||
"approval_required"
|
||||
);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "query.engineering.find_service")["reason"],
|
||||
"approval_required"
|
||||
);
|
||||
let gate_plan = cluster_json(temp.path(), "plan");
|
||||
let gates = gate_plan["approvals_required"].as_array().unwrap();
|
||||
assert_eq!(gates.len(), 1, "{gate_plan}");
|
||||
assert_eq!(gates[0]["resource"], "graph.engineering");
|
||||
assert_eq!(gates[0]["satisfied"], false);
|
||||
assert_eq!(
|
||||
change_for(&mixed, "query.knowledge.find_person")["disposition"],
|
||||
"applied"
|
||||
);
|
||||
// 5A: policy.shared's applies_to narrowed with an unchanged file digest
|
||||
// — now a first-class binding change, applied in the same run.
|
||||
assert_eq!(change_for(&mixed, "policy.shared")["binding_change"], true);
|
||||
assert_eq!(change_for(&mixed, "policy.shared")["disposition"], "applied");
|
||||
assert_eq!(
|
||||
change_for(&mixed, "graph.knowledge")["disposition"],
|
||||
"derived"
|
||||
);
|
||||
// Deterministic ordering: changes sorted by resource address.
|
||||
let order: Vec<&str> = mixed["changes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|change| change["resource"].as_str().unwrap())
|
||||
.collect();
|
||||
let mut sorted = order.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(order, sorted, "{mixed}");
|
||||
// The conclusion: an apply without approval stays blocked; the approved
|
||||
// delete converges the cluster, tombstoning the removed graph.
|
||||
let still_blocked = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(still_blocked["converged"], false, "{still_blocked}");
|
||||
|
||||
let approve = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("--as")
|
||||
.arg("andrew")
|
||||
.arg("cluster")
|
||||
.arg("approve")
|
||||
.arg("graph.engineering")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
));
|
||||
assert_eq!(approve["ok"], true, "{approve}");
|
||||
assert_eq!(approve["approved_by"], "andrew");
|
||||
|
||||
let converge = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(converge["ok"], true, "{converge}");
|
||||
assert_eq!(converge["converged"], true, "{converge}");
|
||||
assert!(!temp.path().join("graphs/engineering.omni").exists());
|
||||
|
||||
let status = cluster_json(temp.path(), "status");
|
||||
assert_eq!(status["observations"]["graph.engineering"]["kind"], "tombstone");
|
||||
let final_plan = cluster_json(temp.path(), "plan");
|
||||
assert!(
|
||||
final_plan["changes"].as_array().unwrap().is_empty(),
|
||||
"{final_plan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_approve_requires_actor() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("approve")
|
||||
.arg("graph.knowledge")
|
||||
.arg("--config")
|
||||
.arg(temp.path()),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("--as"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_declared_graph_created_by_apply() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["ok"], true, "{apply}");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
assert_eq!(change_for(&apply, "graph.knowledge")["disposition"], "applied");
|
||||
assert!(temp.path().join("graphs/knowledge.omni").exists());
|
||||
|
||||
// The created graph is a real graph: the per-graph CLI can open it.
|
||||
let snapshot = output_success(
|
||||
cli()
|
||||
.arg("snapshot")
|
||||
.arg(temp.path().join("graphs/knowledge.omni")),
|
||||
);
|
||||
assert!(!stdout_string(&snapshot).is_empty());
|
||||
|
||||
let plan = cluster_json(temp.path(), "plan");
|
||||
assert!(plan["changes"].as_array().unwrap().is_empty(), "{plan}");
|
||||
let status = cluster_json(temp.path(), "status");
|
||||
assert_eq!(
|
||||
status["resource_statuses"]["graph.knowledge"]["status"],
|
||||
"applied"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_e2e_payload_drift_self_heals() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
init_cluster_derived_graph(temp.path());
|
||||
let import = cluster_json(temp.path(), "import");
|
||||
assert_eq!(import["ok"], true, "{import}");
|
||||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
|
||||
let query_digest = change_for(&apply, "query.knowledge.find_person")["after_digest"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let blob = temp
|
||||
.path()
|
||||
.join("__cluster/resources/query/knowledge/find_person")
|
||||
.join(format!("{query_digest}.gq"));
|
||||
fs::remove_file(&blob).unwrap();
|
||||
|
||||
let status = cluster_json(temp.path(), "status");
|
||||
assert_eq!(status["ok"], true, "{status}");
|
||||
assert!(
|
||||
status["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic["code"] == "catalog_payload_missing"),
|
||||
"{status}"
|
||||
);
|
||||
|
||||
let refresh = cluster_json(temp.path(), "refresh");
|
||||
assert_eq!(refresh["ok"], true, "{refresh}");
|
||||
assert_eq!(
|
||||
refresh["resource_statuses"]["query.knowledge.find_person"]["status"],
|
||||
"drifted"
|
||||
);
|
||||
|
||||
let heal = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(heal["ok"], true, "{heal}");
|
||||
assert_eq!(heal["converged"], true, "{heal}");
|
||||
assert!(blob.exists(), "blob republished");
|
||||
|
||||
let clean = cluster_json(temp.path(), "status");
|
||||
assert!(
|
||||
!clean["diagnostics"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|diagnostic| {
|
||||
diagnostic["code"]
|
||||
.as_str()
|
||||
.is_some_and(|code| code.starts_with("catalog_payload"))
|
||||
}),
|
||||
"{clean}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
//! Stored-query commands and alias resolution.
|
||||
//! Moved verbatim from tests/cli.rs in the modularization.
|
||||
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
mod support;
|
||||
|
||||
use support::*;
|
||||
|
||||
|
||||
#[test]
|
||||
fn query_check_alias_matches_lint_output() {
|
||||
let temp = tempdir().unwrap();
|
||||
let schema_path = temp.path().join("schema.pg");
|
||||
let query_path = temp.path().join("queries.gq");
|
||||
write_file(
|
||||
&schema_path,
|
||||
r#"
|
||||
node Person {
|
||||
name: String
|
||||
}
|
||||
"#,
|
||||
);
|
||||
write_query_file(
|
||||
&query_path,
|
||||
r#"
|
||||
query list_people() {
|
||||
match { $p: Person }
|
||||
return { $p.name }
|
||||
}
|
||||
"#,
|
||||
);
|
||||
|
||||
let lint_output = output_success(
|
||||
cli()
|
||||
.arg("query")
|
||||
.arg("lint")
|
||||
.arg("--query")
|
||||
.arg(&query_path)
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json"),
|
||||
);
|
||||
let check_output = output_success(
|
||||
cli()
|
||||
.arg("query")
|
||||
.arg("check")
|
||||
.arg("--query")
|
||||
.arg(&query_path)
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json"),
|
||||
);
|
||||
|
||||
assert_eq!(stdout_string(&lint_output), stdout_string(&check_output));
|
||||
}
|
||||
|
||||
// Legacy `omnigraph.yaml` `aliases:` invoked via the `--alias` flag were
|
||||
// removed in RFC-011 D4 — operator aliases now live under `omnigraph alias
|
||||
// <name>` (the happy path is covered by system_local's operator-alias e2e).
|
||||
// The legacy file-alias path has no CLI entry point.
|
||||
|
||||
#[test]
|
||||
fn alias_flag_is_removed_from_query() {
|
||||
// RFC-011 D4: `--alias` no longer exists on query/mutate; use `alias <name>`.
|
||||
let output = output_failure(cli().arg("query").arg("--alias").arg("who"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("unexpected argument") && stderr.contains("--alias"),
|
||||
"expected clap to reject --alias on query; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_unknown_name_errors_listing_defined() {
|
||||
// Hermetic: an unknown alias fails before any network, listing defined ones.
|
||||
let home = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
home.path().join("config.yaml"),
|
||||
"servers:\n dev:\n url: https://x\naliases:\n who:\n server: dev\n query: find_person\n",
|
||||
)
|
||||
.unwrap();
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("alias")
|
||||
.arg("nope"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("unknown alias 'nope'") && stderr.contains("who"),
|
||||
"expected an unknown-alias error listing defined aliases; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_rejects_global_scope_flags_that_the_binding_owns() {
|
||||
for (flag, value) in [
|
||||
("--server", "dev"),
|
||||
("--graph", "local"),
|
||||
("--store", "file:///tmp/graph.omni"),
|
||||
("--cluster", "."),
|
||||
("--profile", "prod"),
|
||||
("--as", "act-op"),
|
||||
] {
|
||||
let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("`alias` uses the server, graph, and stored query")
|
||||
&& stderr.contains(flag),
|
||||
"expected {flag} to be rejected by the alias binding guard; got: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() {
|
||||
let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
||||
"queries should point at --cluster, not --config; got: {stderr}"
|
||||
);
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("--server")
|
||||
.arg("prod")
|
||||
.arg("policy")
|
||||
.arg("validate"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
||||
"policy should point at --cluster, not --config; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// RFC-011: `queries validate`/`list` source the registry + schemas from a
|
||||
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
|
||||
|
||||
/// Build a converged single-graph cluster (id `knowledge`) with one stored
|
||||
/// query. `query_block` is the YAML under the graph's `queries:` key.
|
||||
fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir {
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
write_query_file(&dir.join(query_file), query_src);
|
||||
std::fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
format!(
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
|
||||
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
||||
temp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_exits_zero_on_clean_registry() {
|
||||
let cluster = converged_cluster_with_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
" find_person:\n file: ./find_person.gq\n",
|
||||
);
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_import_rejects_a_type_broken_query() {
|
||||
// In the cluster model a stored query is type-checked at the cluster
|
||||
// boundary (import/apply), so a broken query can never reach the applied
|
||||
// state `queries validate` reads — the gate is upstream. `Widget` is not in
|
||||
// the fixture schema, so import must reject it, naming the query.
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
write_query_file(
|
||||
&dir.join("ghost.gq"),
|
||||
"query ghost() { match { $w: Widget } return { $w.name } }",
|
||||
);
|
||||
std::fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
|
||||
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n",
|
||||
)
|
||||
.unwrap();
|
||||
let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
stdout_string(&output),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
combined.contains("ghost"),
|
||||
"cluster import must reject the broken query, naming it; got:\n{combined}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_prints_registered_query() {
|
||||
let cluster = converged_cluster_with_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
" find_person:\n file: ./find_person.gq\n",
|
||||
);
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
|
||||
assert!(
|
||||
stdout.contains("$name: String"),
|
||||
"list should show typed params; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_surfaces_description_and_instruction() {
|
||||
// `@description`/`@instruction` are the whole point of a stored query in a
|
||||
// catalog — they tell an agent/operator what it does and how to invoke it.
|
||||
// The CLI catalog must surface them in both human and --json output, to
|
||||
// match the HTTP `GET /queries` surface.
|
||||
let cluster = converged_cluster_with_query(
|
||||
"described.gq",
|
||||
"query described($name: String) \
|
||||
@description(\"Find a person by exact name.\") \
|
||||
@instruction(\"Use for exact lookups; prefer search for fuzzy matches.\") \
|
||||
{ match { $p: Person { name: $name } } return { $p.age } }",
|
||||
" described:\n file: ./described.gq\n",
|
||||
);
|
||||
|
||||
// Human output.
|
||||
let output = output_success(
|
||||
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(
|
||||
stdout.contains("description: Find a person by exact name."),
|
||||
"human list must show @description; stdout:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("instruction: Use for exact lookups; prefer search for fuzzy matches."),
|
||||
"human list must show @instruction; stdout:\n{stdout}"
|
||||
);
|
||||
|
||||
// --json output.
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let entry = body["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|q| q["name"] == "described")
|
||||
.unwrap();
|
||||
assert_eq!(entry["description"], "Find a person by exact name.");
|
||||
assert_eq!(
|
||||
entry["instruction"],
|
||||
"Use for exact lookups; prefer search for fuzzy matches."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_indents_multiline_annotation_continuation() {
|
||||
// GQ string literals admit newlines, so a `@description`/`@instruction`
|
||||
// can be multiline. Human output must indent continuation lines to align
|
||||
// under the first rather than breaking back to the left margin.
|
||||
let cluster = converged_cluster_with_query(
|
||||
"multi.gq",
|
||||
"query multi($name: String) \
|
||||
@description(\"line one\\nline two\") \
|
||||
{ match { $p: Person { name: $name } } return { $p.age } }",
|
||||
" multi:\n file: ./multi.gq\n",
|
||||
);
|
||||
let output = output_success(
|
||||
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
// " description: " is 17 chars wide; the continuation aligns under it.
|
||||
assert!(
|
||||
stdout.contains(" description: line one\n line two"),
|
||||
"multiline annotation must indent the continuation; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_omits_annotations_when_absent() {
|
||||
// The other half of the contract: a query that declares neither annotation
|
||||
// prints no extra lines and omits both JSON fields entirely. This keeps the
|
||||
// catalog clean rather than echoing empty `description:`/`instruction:`.
|
||||
let cluster = converged_cluster_with_query(
|
||||
"bare.gq",
|
||||
"query bare() { match { $p: Person } return { $p.name } }",
|
||||
" bare:\n file: ./bare.gq\n",
|
||||
);
|
||||
|
||||
// Human output: the query is listed, but no annotation lines.
|
||||
let output = output_success(
|
||||
cli().arg("queries").arg("list").arg("--cluster").arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("bare()"), "stdout:\n{stdout}");
|
||||
assert!(
|
||||
!stdout.contains("description:") && !stdout.contains("instruction:"),
|
||||
"a query without annotations prints no annotation lines; stdout:\n{stdout}"
|
||||
);
|
||||
|
||||
// --json output: both fields omitted (not present as null).
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let entry = body["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|q| q["name"] == "bare")
|
||||
.unwrap();
|
||||
assert!(
|
||||
entry.get("description").is_none() && entry.get("instruction").is_none(),
|
||||
"a query without annotations omits both JSON fields: {entry}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_requires_a_cluster() {
|
||||
// RFC-011: with no --cluster (and no cluster profile), the command errors
|
||||
// loudly rather than reading any omnigraph.yaml.
|
||||
let output = output_failure(cli().arg("queries").arg("validate"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("needs a cluster") || stderr.contains("--cluster"),
|
||||
"queries validate must require a cluster; stderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_graph_filter_selects_one_graph() {
|
||||
// A multi-graph cluster: validate scoped to `knowledge` type-checks only
|
||||
// that graph's registry, ignoring `engineering`'s.
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
write_multi_graph_cluster_fixture(dir);
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--cluster")
|
||||
.arg(dir)
|
||||
.arg("--graph")
|
||||
.arg("knowledge"),
|
||||
);
|
||||
assert!(stdout_string(&output).contains("OK"));
|
||||
}
|
||||
|
|
@ -1,563 +0,0 @@
|
|||
//! init/config scaffolding, schema plan/apply, graphs listing, version.
|
||||
//! Moved verbatim from tests/cli.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use lance::index::DatasetIndexExt;
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
use serde_json::Value;
|
||||
use tempfile::tempdir;
|
||||
|
||||
mod support;
|
||||
|
||||
use support::*;
|
||||
|
||||
|
||||
#[test]
|
||||
fn version_command_prints_current_cli_version() {
|
||||
let output = output_success(cli().arg("version"));
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert_eq!(
|
||||
stdout.trim(),
|
||||
format!("omnigraph {}", env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_groups_commands_by_capability() {
|
||||
// RFC-010 Slice 2 / RFC-011 Slice B: `--help` clusters commands (declaration
|
||||
// order in the Command enum) and explains the capability each needs in an
|
||||
// after_help legend. Pinned lightly — the legend phrase + the cluster
|
||||
// ordering — to avoid brittle full-text assertions on clap's help body.
|
||||
let output = output_success(cli().arg("--help"));
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(
|
||||
stdout.contains("COMMANDS BY CAPABILITY"),
|
||||
"capability legend (after_help) missing from --help:\n{stdout}"
|
||||
);
|
||||
|
||||
// The Commands list precedes the legend, so first occurrences sit in the
|
||||
// list and must appear in order: an `any` data verb, then a `direct` verb,
|
||||
// then the `control` verb.
|
||||
let pos = |needle: &str| {
|
||||
stdout
|
||||
.find(needle)
|
||||
.unwrap_or_else(|| panic!("'{needle}' not found in --help:\n{stdout}"))
|
||||
};
|
||||
assert!(
|
||||
pos("query") < pos("optimize"),
|
||||
"data (any) commands should be listed before direct commands"
|
||||
);
|
||||
assert!(
|
||||
pos("optimize") < pos("cluster"),
|
||||
"direct commands should be listed before the control command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_creates_graph_successfully_on_missing_local_directory() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema = fixture("test.pg");
|
||||
|
||||
let output = output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&graph));
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(stdout.contains("initialized"));
|
||||
assert!(graph.join("_schema.pg").exists());
|
||||
assert!(graph.join("__manifest").exists());
|
||||
// RFC-008 stage 3: init no longer scaffolds the legacy config file.
|
||||
assert!(!temp.path().join("omnigraph.yaml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_plan_json_reports_supported_additive_change() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("next.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
||||
" age: I32?\n}",
|
||||
" age: I32?\n nickname: String?\n}",
|
||||
);
|
||||
fs::write(&schema_path, next_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("plan")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
|
||||
assert_eq!(payload["supported"], true);
|
||||
assert_eq!(payload["step_count"], 1);
|
||||
assert_eq!(payload["steps"][0]["kind"], "add_property");
|
||||
assert_eq!(payload["steps"][0]["type_kind"], "node");
|
||||
assert_eq!(payload["steps"][0]["type_name"], "Person");
|
||||
assert_eq!(payload["steps"][0]["property_name"], "nickname");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_plan_with_server_flag_errors_wrong_plane() {
|
||||
// RFC-010 Slice 1: `schema plan` is storage-plane while `schema show/apply`
|
||||
// are data-plane — the guard rejects --server on plan with the per-subcommand
|
||||
// label (proving command_plane/command_label descend into the nested enum).
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("plan")
|
||||
.arg("--schema")
|
||||
.arg(fixture("test.pg"))
|
||||
.arg("--server")
|
||||
.arg("prod"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("`schema plan` is a direct (storage-native) command")
|
||||
&& stderr.contains("Pass a storage URI."),
|
||||
"schema plan wrong-capability message not found; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_plan_json_reports_unsupported_type_change() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("breaking.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let breaking_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace("age: I32?", "age: I64?");
|
||||
fs::write(&schema_path, breaking_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("plan")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
|
||||
assert_eq!(payload["supported"], false);
|
||||
assert!(payload["steps"].as_array().unwrap().iter().any(|step| {
|
||||
step["kind"] == "unsupported_change"
|
||||
&& step["entity"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("Person.age")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_json_applies_supported_migration() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("next.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
||||
" age: I32?\n}",
|
||||
" age: I32?\n nickname: String?\n}",
|
||||
);
|
||||
fs::write(&schema_path, next_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
|
||||
assert_eq!(payload["supported"], true);
|
||||
assert_eq!(payload["applied"], true);
|
||||
assert_eq!(payload["step_count"], 1);
|
||||
|
||||
let db = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(Omnigraph::open(graph.to_string_lossy().as_ref()))
|
||||
.unwrap();
|
||||
assert!(
|
||||
db.catalog().node_types["Person"]
|
||||
.properties
|
||||
.contains_key("nickname")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_human_reports_noop() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = fixture("test.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg(&graph),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(stdout.contains("applied: no"));
|
||||
assert!(stdout.contains("no schema changes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_json_renames_type_and_updates_snapshot() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("rename.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let renamed_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace("node Person {\n", "node Human @rename_from(\"Person\") {\n")
|
||||
.replace("edge Knows: Person -> Person", "edge Knows: Human -> Human")
|
||||
.replace(
|
||||
"edge WorksAt: Person -> Company",
|
||||
"edge WorksAt: Human -> Company",
|
||||
);
|
||||
fs::write(&schema_path, renamed_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let db = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(Omnigraph::open(graph.to_string_lossy().as_ref()))
|
||||
.unwrap();
|
||||
let snapshot = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(db.snapshot_of(ReadTarget::branch("main")))
|
||||
.unwrap();
|
||||
assert!(snapshot.entry("node:Human").is_some());
|
||||
assert!(snapshot.entry("node:Person").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_json_renames_property_and_updates_catalog() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("rename-property.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let renamed_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace("age: I32?", "years: I32? @rename_from(\"age\")");
|
||||
fs::write(&schema_path, renamed_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let db = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(Omnigraph::open(graph.to_string_lossy().as_ref()))
|
||||
.unwrap();
|
||||
let person = &db.catalog().node_types["Person"];
|
||||
assert!(person.properties.contains_key("years"));
|
||||
assert!(!person.properties.contains_key("age"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_json_adds_index_for_existing_property() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("index.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let before_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let db = Omnigraph::open(graph.to_string_lossy().as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
||||
let dataset = snapshot.open("node:Person").await.unwrap();
|
||||
dataset.load_indices().await.unwrap().len()
|
||||
});
|
||||
|
||||
let indexed_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace("name: String @key", "name: String @key @index");
|
||||
fs::write(&schema_path, indexed_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let after_index_count = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let db = Omnigraph::open(graph.to_string_lossy().as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
||||
let dataset = snapshot.open("node:Person").await.unwrap();
|
||||
dataset.load_indices().await.unwrap().len()
|
||||
});
|
||||
// iss-848: `schema apply` records the `@index` intent but defers the physical
|
||||
// index build (materialized later by ensure_indices/optimize; on this empty
|
||||
// table nothing builds anyway). So the physical index count is unchanged.
|
||||
assert_eq!(
|
||||
after_index_count, before_index_count,
|
||||
"schema apply records @index intent but defers the physical build (iss-848)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_rejects_unsupported_plan() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("breaking.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let breaking_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace("age: I32?", "age: I64?");
|
||||
fs::write(&schema_path, breaking_schema).unwrap();
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg(&graph),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("changing property type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_rejects_when_non_main_branch_exists() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("next.pg");
|
||||
init_graph(&graph);
|
||||
output_success(
|
||||
cli()
|
||||
.arg("branch")
|
||||
.arg("create")
|
||||
.arg("--from")
|
||||
.arg("main")
|
||||
.arg("--uri")
|
||||
.arg(&graph)
|
||||
.arg("feature"),
|
||||
);
|
||||
|
||||
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
||||
" age: I32?\n}",
|
||||
" age: I32?\n nickname: String?\n}",
|
||||
);
|
||||
fs::write(&schema_path, next_schema).unwrap();
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg(&graph),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("schema apply requires a graph with only main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_allow_data_loss_flag_promotes_drops_to_hard() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("drop-age.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
// Drop the nullable `age` column.
|
||||
let next_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace(" age: I32?\n", "");
|
||||
fs::write(&schema_path, next_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--allow-data-loss")
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let drop_step = payload["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["kind"] == "drop_property")
|
||||
.expect("plan should include a drop_property step");
|
||||
assert_eq!(
|
||||
drop_step["mode"], "hard",
|
||||
"--allow-data-loss should promote Soft → Hard; full step: {drop_step}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_without_allow_data_loss_keeps_soft_drops() {
|
||||
// Symmetric to the above: same schema change without the flag →
|
||||
// drops stay Soft. Pins default semantics against accidental Hard
|
||||
// promotion if a future refactor changes the option threading.
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let schema_path = temp.path().join("drop-age-soft.pg");
|
||||
init_graph(&graph);
|
||||
|
||||
let next_schema = fs::read_to_string(fixture("test.pg"))
|
||||
.unwrap()
|
||||
.replace(" age: I32?\n", "");
|
||||
fs::write(&schema_path, next_schema).unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let drop_step = payload["steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["kind"] == "drop_property")
|
||||
.expect("plan should include a drop_property step");
|
||||
assert_eq!(
|
||||
drop_step["mode"], "soft",
|
||||
"no flag should leave drops Soft; full step: {drop_step}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_plan_parity_cli_and_sdk() {
|
||||
// Same .pg through `Omnigraph::plan_schema_with_options` (SDK) and
|
||||
// `omnigraph schema plan --json` (CLI). Asserts the steps array is
|
||||
// byte-identical after JSON round-trip. HTTP doesn't expose a
|
||||
// separate /schema/plan route — that side of parity is covered by
|
||||
// the HTTP soft/hard drop tests, which exercise apply with
|
||||
// identical fixtures.
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
let schema_path = temp.path().join("plan-parity.pg");
|
||||
let next_schema = fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
||||
" age: I32?\n}",
|
||||
" age: I32?\n nickname: String?\n}",
|
||||
);
|
||||
fs::write(&schema_path, &next_schema).unwrap();
|
||||
|
||||
// CLI side.
|
||||
let cli_output = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("plan")
|
||||
.arg("--schema")
|
||||
.arg(&schema_path)
|
||||
.arg("--json")
|
||||
.arg(&graph),
|
||||
);
|
||||
let cli_payload: Value = serde_json::from_slice(&cli_output.stdout).unwrap();
|
||||
|
||||
// SDK side: open graph, call plan_schema.
|
||||
let plan = tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let db = Omnigraph::open(graph.to_string_lossy().as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
db.plan_schema(&next_schema).await.unwrap()
|
||||
});
|
||||
let sdk_steps = serde_json::to_value(&plan.steps).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
cli_payload["steps"], sdk_steps,
|
||||
"CLI plan steps must match SDK plan steps for identical input",
|
||||
);
|
||||
assert_eq!(cli_payload["supported"], plan.supported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphs_subcommand_help_lists_list_only() {
|
||||
let output = output_success(cli().arg("graphs").arg("--help"));
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(
|
||||
stdout.contains("list"),
|
||||
"expected `list` subcommand in help output:\n{stdout}"
|
||||
);
|
||||
let lowered = stdout.to_lowercase();
|
||||
assert!(
|
||||
!lowered.contains("create a new graph"),
|
||||
"graph create should not be in v0.6.0 help; got:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!lowered.contains("delete a graph"),
|
||||
"graph delete should not be in v0.6.0 help; got:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphs_list_against_local_uri_errors_with_remote_only_message() {
|
||||
// RFC-011: `graphs list` is served-only; a `--store` (local) address has no
|
||||
// enumeration endpoint, so it fails loudly pointing at a server / cluster.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("graphs")
|
||||
.arg("list")
|
||||
.arg("--store")
|
||||
.arg("/tmp/local"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
assert!(
|
||||
stderr.contains("remote multi-graph server"),
|
||||
"expected a remote-server rejection in stderr; got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
//! RFC-009 Phase 1 — the embedded/remote parity referee.
|
||||
//!
|
||||
//! For every CLI verb with an `is_remote` fork, run the identical
|
||||
//! invocation against (a) the local graph directly and (b) a spawned
|
||||
//! server on a twin copy of the same graph, with the SAME actor on both
|
||||
//! arms (local `--as act-parity`; remote bearer token resolving to
|
||||
//! `act-parity`). Scrub the declared-volatile allowlist
|
||||
//! (`support::scrub_volatile` — ids, wall-clock, transport locations);
|
||||
//! everything else must match exactly.
|
||||
//!
|
||||
//! This test PINS behavior; it does not idealize it. Genuine divergences
|
||||
//! discovered here are recorded in `KNOWN_DIVERGENCES` below (and filed),
|
||||
//! never silently repaired — repairs are Phase 3's job, gated by this
|
||||
//! referee staying green through the refactor.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
/// Divergences between the arms that exist today, pinned as expectations.
|
||||
/// Removing an entry requires the corresponding behavior change to be a
|
||||
/// deliberate, release-noted decision (RFC-009 Compatibility).
|
||||
const KNOWN_DIVERGENCES: &[&str] = &[
|
||||
// populated by the rows below as they are written
|
||||
];
|
||||
|
||||
/// One matched setup per row: twin graphs + the parity Cedar bundle on the
|
||||
/// served arm. The local (`--store`) arm carries no policy (RFC-011); the
|
||||
/// bundle is permissive for `act-parity`, so the arms still agree.
|
||||
struct Parity {
|
||||
_temp: TempDir,
|
||||
local: std::path::PathBuf,
|
||||
server: TestServer,
|
||||
}
|
||||
|
||||
fn parity() -> Parity {
|
||||
let (temp, local, remote) = twin_graphs();
|
||||
// RFC-011 cluster-only: the remote arm is served from a converged
|
||||
// cluster directory (one graph, id `parity`), seeded with the same
|
||||
// fixture data as the local twin.
|
||||
let cluster_dir = parity_configs(temp.path(), &local, &remote);
|
||||
let server = spawn_server_with_cluster_env(
|
||||
&cluster_dir,
|
||||
&[(
|
||||
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
||||
r#"{"act-parity":"parity-tok"}"#,
|
||||
)],
|
||||
);
|
||||
Parity {
|
||||
_temp: temp,
|
||||
local,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
impl Parity {
|
||||
fn run(&self, args: &[&str]) -> (std::process::Output, std::process::Output) {
|
||||
run_both(&self.local, &self.server.base_url, args)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_parity(verb: &str, local: &std::process::Output, remote: &std::process::Output) {
|
||||
assert_eq!(
|
||||
local.status.code(),
|
||||
remote.status.code(),
|
||||
"{verb}: exit codes diverge\nlocal: {local:?}\nremote: {remote:?}"
|
||||
);
|
||||
if local.status.success() {
|
||||
let local_json = scrubbed_json(local);
|
||||
let remote_json = scrubbed_json(remote);
|
||||
assert_eq!(
|
||||
local_json, remote_json,
|
||||
"{verb}: scrubbed JSON diverges (left=local, right=remote)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_query() {
|
||||
let p = parity();
|
||||
let query = fixture("test.gq");
|
||||
let (l, r) = p.run(&[
|
||||
"query",
|
||||
"--query",
|
||||
query.to_str().unwrap(),
|
||||
"get_person",
|
||||
"--params",
|
||||
r#"{"name":"Alice"}"#,
|
||||
"--json",
|
||||
],
|
||||
);
|
||||
assert_parity("query", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_schema_show() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["schema", "show", "--json"]);
|
||||
assert_parity("schema show", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_snapshot() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["snapshot", "--json"]);
|
||||
assert_parity("snapshot", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_branch_list() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["branch", "list", "--json"]);
|
||||
assert_parity("branch list", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_commit_list() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["commit", "list", "--json"]);
|
||||
assert_parity("commit list", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_mutate() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&[
|
||||
"mutate",
|
||||
"-e",
|
||||
"query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }",
|
||||
"--params",
|
||||
r#"{"name":"Parity","age":7}"#,
|
||||
"--json",
|
||||
],
|
||||
);
|
||||
assert_parity("mutate", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_branch_create_delete() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["branch", "create", "--from", "main", "parity-branch", "--json"],
|
||||
);
|
||||
assert_parity("branch create", &l, &r);
|
||||
// `branch delete` is destructive: the served (remote) arm is non-local and
|
||||
// requires consent (RFC-011 Decision 9), so the row passes `--yes` to test
|
||||
// the operation itself, not the safety gate. The local arm ignores `--yes`.
|
||||
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--yes", "--json"],
|
||||
);
|
||||
assert_parity("branch delete", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_branch_merge() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["branch", "create", "--from", "main", "feature", "--json"],
|
||||
);
|
||||
assert_parity("branch create (merge setup)", &l, &r);
|
||||
let (l, r) = p.run(&["branch", "merge", "feature", "--into", "main", "--json"],
|
||||
);
|
||||
assert_parity("branch merge", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_load() {
|
||||
let p = parity();
|
||||
let data = p.local.parent().unwrap().join("rows.jsonl");
|
||||
std::fs::write(
|
||||
&data,
|
||||
"{\"type\":\"Person\",\"data\":{\"name\":\"Loaded\",\"age\":1}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (l, r) = p.run(&[
|
||||
"load",
|
||||
"--mode",
|
||||
"merge",
|
||||
"--data",
|
||||
data.to_str().unwrap(),
|
||||
"--json",
|
||||
],
|
||||
);
|
||||
assert_parity("load", &l, &r);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_export() {
|
||||
let p = parity();
|
||||
let (l, r) = p.run(&["export"]);
|
||||
// export emits a JSONL STREAM, not a single `--json` document, so the
|
||||
// scrubbed-single-doc `assert_parity` doesn't apply — compare line-wise.
|
||||
// The twin graphs are byte-copies of one loaded fixture, so rows carry
|
||||
// identical ids/versions and need no scrubbing; sort the lines so any
|
||||
// cross-arm row-ordering difference doesn't masquerade as a divergence.
|
||||
assert_eq!(
|
||||
l.status.code(),
|
||||
r.status.code(),
|
||||
"export: exit codes diverge\nlocal {l:?}\nremote {r:?}"
|
||||
);
|
||||
assert!(l.status.success(), "export local arm failed: {l:?}");
|
||||
let mut local_lines: Vec<&str> = std::str::from_utf8(&l.stdout).unwrap().lines().collect();
|
||||
let mut remote_lines: Vec<&str> = std::str::from_utf8(&r.stdout).unwrap().lines().collect();
|
||||
assert!(
|
||||
!local_lines.is_empty(),
|
||||
"export produced no rows — the parity check would be vacuous"
|
||||
);
|
||||
local_lines.sort_unstable();
|
||||
remote_lines.sort_unstable();
|
||||
assert_eq!(
|
||||
local_lines, remote_lines,
|
||||
"export: JSONL streams diverge (left=local, right=remote)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- error parity: exit codes must match for shared failure cases ----
|
||||
|
||||
#[test]
|
||||
fn parity_errors_share_exit_codes() {
|
||||
let p = parity();
|
||||
|
||||
// unknown branch on merge
|
||||
let (l, r) = p.run(&["branch", "merge", "no-such-branch", "--into", "main", "--json"],
|
||||
);
|
||||
assert_eq!(
|
||||
(l.status.success(), r.status.success()),
|
||||
(false, false),
|
||||
"merge of unknown branch must fail on both arms\nlocal {l:?}\nremote {r:?}"
|
||||
);
|
||||
|
||||
// unknown query name in the source
|
||||
let query = fixture("test.gq");
|
||||
let (l, r) = p.run(&[
|
||||
"query",
|
||||
"--query",
|
||||
query.to_str().unwrap(),
|
||||
"no_such_query",
|
||||
"--json",
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
(l.status.success(), r.status.success()),
|
||||
(false, false),
|
||||
"unknown query name must fail on both arms\nlocal {l:?}\nremote {r:?}"
|
||||
);
|
||||
|
||||
// Discovery (parity HOLDS, behavior surprising): an inline query run
|
||||
// with a declared-but-unbound param does NOT error on either arm — it
|
||||
// returns every row (the filter drops), while the stored-query invoke
|
||||
// path hard-errors 'parameter not provided'. Pinned here as agreeing
|
||||
// behavior; the cross-path asymmetry is filed separately.
|
||||
let (l, r) = p.run(&[
|
||||
"query",
|
||||
"--query",
|
||||
query.to_str().unwrap(),
|
||||
"get_person",
|
||||
"--json",
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
(l.status.success(), r.status.success()),
|
||||
(true, true),
|
||||
"unbound-param inline query currently SUCCEEDS on both arms (matches-all)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- documented exclusions (not bugs; the Phase 4 capability table) ----
|
||||
//
|
||||
// - `graphs list`: server-only today; becomes Both-capability when the
|
||||
// embedded arm enumerates the cluster catalog (RFC-009 open Q3, answered).
|
||||
// - `ingest`: deprecated alias of load; its remote arm rides the deprecated
|
||||
// /ingest route. The canonical `load` verb targets `/load` (RFC-009 Phase 5,
|
||||
// landed) — `parity_load` exercises it on the remote arm.
|
||||
// - `init`, `optimize`, `repair`, `cleanup`, `cluster *`: storage-plane by
|
||||
// design (must work with the server down); Phase 4 declares this.
|
||||
#[allow(dead_code)]
|
||||
const EXCLUSIONS_DOCUMENTED: () = ();
|
||||
|
||||
#[test]
|
||||
fn known_divergences_ledger_is_current() {
|
||||
// The ledger exists so removals are deliberate: an empty list with all
|
||||
// rows green means the arms agree everywhere the matrix looks.
|
||||
assert!(
|
||||
KNOWN_DIVERGENCES.is_empty(),
|
||||
"divergences are pinned: {KNOWN_DIVERGENCES:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -12,24 +12,12 @@ use reqwest::blocking::Client;
|
|||
use serde_json::Value;
|
||||
use tempfile::{TempDir, tempdir};
|
||||
|
||||
/// Hermetic default: point OMNIGRAPH_HOME at a path that exists on no
|
||||
/// machine, so spawned binaries never read the developer's real
|
||||
/// ~/.omnigraph/ (an absent operator config is an empty layer). Tests
|
||||
/// exercising the operator layer override the var explicitly.
|
||||
pub const HERMETIC_OPERATOR_HOME: &str = "/nonexistent/omnigraph-test-home";
|
||||
|
||||
pub fn cli() -> Command {
|
||||
let mut command = Command::cargo_bin("omnigraph").unwrap();
|
||||
command.env("OMNIGRAPH_HOME", HERMETIC_OPERATOR_HOME);
|
||||
command.env_remove("OMNIGRAPH_CONFIG");
|
||||
command
|
||||
Command::cargo_bin("omnigraph").unwrap()
|
||||
}
|
||||
|
||||
pub fn cli_process() -> StdCommand {
|
||||
let mut command = StdCommand::new(assert_cmd::cargo::cargo_bin("omnigraph"));
|
||||
command.env("OMNIGRAPH_HOME", HERMETIC_OPERATOR_HOME);
|
||||
command.env_remove("OMNIGRAPH_CONFIG");
|
||||
command
|
||||
StdCommand::new(assert_cmd::cargo::cargo_bin("omnigraph"))
|
||||
}
|
||||
|
||||
fn server_process() -> StdCommand {
|
||||
|
|
@ -105,15 +93,7 @@ pub fn init_graph(graph: &Path) {
|
|||
|
||||
pub fn load_fixture(graph: &Path) {
|
||||
let data = fixture("test.jsonl");
|
||||
output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg("--data")
|
||||
.arg(&data)
|
||||
.arg(graph),
|
||||
);
|
||||
output_success(cli().arg("load").arg("--data").arg(&data).arg(graph));
|
||||
}
|
||||
|
||||
pub fn write_jsonl(path: &Path, rows: &str) {
|
||||
|
|
@ -232,42 +212,6 @@ pub fn spawn_server_with_config(config: &Path) -> TestServer {
|
|||
spawn_server_process(command)
|
||||
}
|
||||
|
||||
pub fn spawn_server_with_cluster(cluster_dir: &Path) -> TestServer {
|
||||
let mut command = server_process();
|
||||
command.arg("--cluster").arg(cluster_dir).arg("--unauthenticated");
|
||||
spawn_server_process(command)
|
||||
}
|
||||
|
||||
/// Cluster boot with the server process's cwd set explicitly — used to prove
|
||||
/// rule 0 never touches the cwd omnigraph.yaml search.
|
||||
pub fn spawn_server_with_cluster_in(cluster_dir: &Path, cwd: &Path) -> TestServer {
|
||||
let mut command = server_process();
|
||||
command
|
||||
.arg("--cluster")
|
||||
.arg(cluster_dir)
|
||||
.arg("--unauthenticated")
|
||||
.current_dir(cwd);
|
||||
spawn_server_process(command)
|
||||
}
|
||||
|
||||
pub fn spawn_server_with_cluster_env(cluster_dir: &Path, envs: &[(&str, &str)]) -> TestServer {
|
||||
let mut command = server_process();
|
||||
command.arg("--cluster").arg(cluster_dir);
|
||||
for (name, value) in envs {
|
||||
command.env(name, value);
|
||||
}
|
||||
spawn_server_process(command)
|
||||
}
|
||||
|
||||
pub fn spawn_server_with_env(graph: &Path, envs: &[(&str, &str)]) -> TestServer {
|
||||
let mut command = server_process();
|
||||
command.arg(graph);
|
||||
for (name, value) in envs {
|
||||
command.env(name, value);
|
||||
}
|
||||
spawn_server_process(command)
|
||||
}
|
||||
|
||||
pub fn spawn_server_with_config_env(config: &Path, envs: &[(&str, &str)]) -> TestServer {
|
||||
let mut command = server_process();
|
||||
command.arg("--config").arg(config);
|
||||
|
|
@ -338,646 +282,3 @@ impl SystemGraph {
|
|||
spawn_server_with_config_env(config, envs)
|
||||
}
|
||||
}
|
||||
|
||||
/// A converged cluster directory the server can boot from (`--cluster`),
|
||||
/// serving one graph seeded with the standard fixture. Holds the temp dir
|
||||
/// alive for the test's lifetime.
|
||||
pub struct ClusterFixture {
|
||||
_temp: TempDir,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClusterFixture {
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a converged cluster (RFC-011 cluster-only serving) with a single
|
||||
/// graph `graph_id`, seeded with the `test.jsonl` fixture so reads return
|
||||
/// data. When `policy_yaml` is `Some`, the bundle is bound to the graph
|
||||
/// scope. The server boots from the returned path via `--cluster`.
|
||||
pub fn converged_loaded_cluster(graph_id: &str, policy_yaml: Option<&str>) -> ClusterFixture {
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path().to_path_buf();
|
||||
fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
|
||||
let policy_block = match policy_yaml {
|
||||
Some(source) => {
|
||||
fs::write(dir.join("graph.policy.yaml"), source).unwrap();
|
||||
format!(
|
||||
"policies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [{graph_id}]\n"
|
||||
)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
format!(
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n {graph_id}:\n schema: ./graph.pg\n{policy_block}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(&dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(&dir));
|
||||
|
||||
let served_root = dir.join("graphs").join(format!("{graph_id}.omni"));
|
||||
output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--data")
|
||||
.arg(fixture("test.jsonl"))
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg(&served_root),
|
||||
);
|
||||
|
||||
ClusterFixture { _temp: temp, dir }
|
||||
}
|
||||
|
||||
// ---- helpers moved from the monolithic tests/cli.rs ----
|
||||
#[allow(unused_imports)]
|
||||
use lance::Dataset;
|
||||
#[allow(unused_imports)]
|
||||
use lance::index::DatasetIndexExt;
|
||||
#[allow(unused_imports)]
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
|
||||
pub const POLICY_YAML: &str = r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew, act-bruno]
|
||||
admins: [act-andrew]
|
||||
protected_branches: [main]
|
||||
rules:
|
||||
- id: team-read
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
- id: team-write
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [change]
|
||||
branch_scope: unprotected
|
||||
- id: admins-promote
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [branch_merge]
|
||||
target_branch_scope: protected
|
||||
"#;
|
||||
|
||||
pub const POLICY_TESTS_YAML: &str = r#"
|
||||
version: 1
|
||||
cases:
|
||||
- id: allow-feature-write
|
||||
actor: act-andrew
|
||||
action: change
|
||||
branch: feature
|
||||
expect: allow
|
||||
- id: deny-main-write
|
||||
actor: act-bruno
|
||||
action: change
|
||||
branch: main
|
||||
expect: deny
|
||||
"#;
|
||||
|
||||
pub fn manifest_dataset_version(graph: &std::path::Path) -> u64 {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
Omnigraph::open(graph.to_string_lossy().as_ref())
|
||||
.await
|
||||
.unwrap()
|
||||
.snapshot_of(ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap()
|
||||
.version()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn forge_person_delete_drift(graph: &std::path::Path) -> (u64, u64) {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let uri = graph.to_string_lossy();
|
||||
let db = Omnigraph::open(uri.as_ref()).await.unwrap();
|
||||
let snap = db
|
||||
.snapshot_of(ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap();
|
||||
let entry = snap.entry("node:Person").unwrap();
|
||||
let full_path = format!("{}/{}", uri.trim_end_matches('/'), entry.table_path);
|
||||
let mut ds = Dataset::open(&full_path).await.unwrap();
|
||||
let deleted = ds.delete("name = 'Alice'").await.unwrap();
|
||||
assert_eq!(deleted.num_deleted_rows, 1);
|
||||
let head = deleted.new_dataset.version().version;
|
||||
assert!(head > entry.table_version);
|
||||
(entry.table_version, head)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_policy_config_fixture(root: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let config = root.join("omnigraph.yaml");
|
||||
let policy = root.join("policy.yaml");
|
||||
fs::write(
|
||||
&config,
|
||||
r#"
|
||||
project:
|
||||
name: policy-test-graph
|
||||
policy:
|
||||
file: ./policy.yaml
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&policy, POLICY_YAML).unwrap();
|
||||
fs::write(root.join("policy.tests.yaml"), POLICY_TESTS_YAML).unwrap();
|
||||
(config, policy)
|
||||
}
|
||||
|
||||
pub fn write_cluster_config_fixture(root: &std::path::Path) {
|
||||
fs::write(
|
||||
root.join("people.pg"),
|
||||
r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("people.gq"),
|
||||
r#"
|
||||
query find_person($name: String) {
|
||||
match { $p: Person { name: $name } }
|
||||
return { $p.name, $p.age }
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap();
|
||||
fs::write(
|
||||
root.join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
metadata:
|
||||
name: company-brain
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
policies:
|
||||
base:
|
||||
file: ./base.policy.yaml
|
||||
applies_to: [knowledge]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn init_cluster_derived_graph(root: &std::path::Path) {
|
||||
init_named_cluster_graph(root, "knowledge", "people.pg");
|
||||
}
|
||||
|
||||
pub fn init_named_cluster_graph(root: &std::path::Path, graph_id: &str, schema_file: &str) {
|
||||
let graph_dir = root.join("graphs");
|
||||
fs::create_dir_all(&graph_dir).unwrap();
|
||||
output_success(
|
||||
cli()
|
||||
.arg("init")
|
||||
.arg("--schema")
|
||||
.arg(root.join(schema_file))
|
||||
.arg(graph_dir.join(format!("{graph_id}.omni"))),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn write_cluster_lock(root: &std::path::Path, lock_id: &str, operation: &str) {
|
||||
let state_dir = root.join("__cluster");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::write(
|
||||
state_dir.join("lock.json"),
|
||||
format!(
|
||||
r#"{{"version":1,"lock_id":"{lock_id}","operation":"{operation}","created_at":"1970-01-01T00:00:00Z","pid":123}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn write_cluster_applyable_state(root: &std::path::Path) -> serde_json::Value {
|
||||
let validate = parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(root)
|
||||
.arg("--json"),
|
||||
));
|
||||
let schema_digest = validate["resource_digests"]["schema.knowledge"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let state_dir = root.join("__cluster");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::write(
|
||||
state_dir.join("state.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"version": 1,
|
||||
"state_revision": 1,
|
||||
"applied_revision": {{
|
||||
"resources": {{
|
||||
"graph.knowledge": {{ "digest": "seed" }},
|
||||
"schema.knowledge": {{ "digest": "{schema_digest}" }}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
validate
|
||||
}
|
||||
|
||||
pub fn cluster_json(root: &std::path::Path, command: &str) -> serde_json::Value {
|
||||
parse_stdout_json(&output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg(command)
|
||||
.arg("--config")
|
||||
.arg(root)
|
||||
.arg("--json"),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn write_multi_graph_cluster_fixture(root: &std::path::Path) {
|
||||
write_cluster_config_fixture(root);
|
||||
fs::write(
|
||||
root.join("services.pg"),
|
||||
r#"
|
||||
node Service {
|
||||
name: String @key
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("services.gq"),
|
||||
r#"
|
||||
query find_service($name: String) {
|
||||
match { $s: Service { name: $name } }
|
||||
return { $s.name }
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("cluster_wide.policy.yaml"), "rules: []\n").unwrap();
|
||||
fs::write(root.join("shared.policy.yaml"), "rules: []\n").unwrap();
|
||||
fs::write(
|
||||
root.join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
metadata:
|
||||
name: company-brain
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
engineering:
|
||||
schema: ./services.pg
|
||||
queries:
|
||||
find_service:
|
||||
file: ./services.gq
|
||||
policies:
|
||||
shared:
|
||||
file: ./shared.policy.yaml
|
||||
applies_to: [knowledge, engineering]
|
||||
cluster_wide:
|
||||
file: ./cluster_wide.policy.yaml
|
||||
applies_to: [cluster]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn change_for<'j>(json: &'j serde_json::Value, resource: &str) -> &'j serde_json::Value {
|
||||
json["changes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|change| change["resource"] == resource)
|
||||
.unwrap_or_else(|| panic!("missing change for {resource}: {json}"))
|
||||
}
|
||||
|
||||
pub fn write_seed_fixture(root: &std::path::Path) -> std::path::PathBuf {
|
||||
fs::create_dir_all(root.join("data")).unwrap();
|
||||
fs::create_dir_all(root.join("build")).unwrap();
|
||||
let raw_seed = root.join("data/seed.jsonl");
|
||||
let seed = root.join("seed.yaml");
|
||||
|
||||
fs::write(
|
||||
&raw_seed,
|
||||
concat!(
|
||||
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n",
|
||||
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::write(
|
||||
&seed,
|
||||
concat!(
|
||||
"graph:\n",
|
||||
" slug: mr-context-graph\n",
|
||||
"sources:\n",
|
||||
" raw_seed: ./data/seed.jsonl\n",
|
||||
"artifacts:\n",
|
||||
" embedded_seed: ./build/seed.embedded.jsonl\n",
|
||||
"embeddings:\n",
|
||||
" model: gemini-embedding-2-preview\n",
|
||||
" dimension: 4\n",
|
||||
" types:\n",
|
||||
" Decision:\n",
|
||||
" target: embedding\n",
|
||||
" fields: [slug, intent]\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
seed
|
||||
}
|
||||
|
||||
pub fn write_seed_fixture_with_edge(root: &std::path::Path) -> std::path::PathBuf {
|
||||
let seed = write_seed_fixture(root);
|
||||
let raw_seed = root.join("data/seed.jsonl");
|
||||
fs::write(
|
||||
&raw_seed,
|
||||
concat!(
|
||||
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-alpha\",\"intent\":\"Alpha ship\"}}\n",
|
||||
"{\"type\":\"Decision\",\"data\":{\"slug\":\"dec-beta\",\"intent\":\"Beta ship\",\"embedding\":[0.1,0.2]}}\n",
|
||||
"{\"edge\":\"Triggered\",\"from\":\"sig-alpha\",\"to\":\"dec-alpha\"}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
seed
|
||||
}
|
||||
|
||||
pub fn read_embedded_rows(path: std::path::PathBuf) -> Vec<Value> {
|
||||
fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn queries_test_config(graph_uri: &str, entry: &str, gq_file: &str) -> String {
|
||||
format!(
|
||||
"graphs:\n local:\n uri: '{}'\n queries:\n {entry}:\n file: ./{gq_file}\n\
|
||||
cli:\n graph: local\npolicy: {{}}\n",
|
||||
graph_uri.replace('\'', "''")
|
||||
)
|
||||
}
|
||||
|
||||
// ---- RFC-009 Phase 1: parity-matrix harness ----
|
||||
|
||||
/// Twin graphs for embedded-vs-remote comparison: the same loaded fixture
|
||||
/// copied to two roots, so write verbs can run once per arm on identical
|
||||
/// state. Returns (tempdir-guard, local_graph, remote_graph).
|
||||
pub fn twin_graphs() -> (TempDir, PathBuf, PathBuf) {
|
||||
let temp = tempdir().unwrap();
|
||||
let seed = temp.path().join("seed");
|
||||
fs::create_dir_all(&seed).unwrap();
|
||||
let graph = seed.join("server.omni");
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
let local = temp.path().join("local.omni");
|
||||
let remote = temp.path().join("remote.omni");
|
||||
copy_dir(&graph, &local);
|
||||
copy_dir(&graph, &remote);
|
||||
(temp, local, remote)
|
||||
}
|
||||
|
||||
pub fn copy_dir(from: &Path, to: &Path) {
|
||||
fs::create_dir_all(to).unwrap();
|
||||
for entry in fs::read_dir(from).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let target = to.join(entry.file_name());
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&entry.path(), &target);
|
||||
} else {
|
||||
fs::copy(entry.path(), &target).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrub declared-volatile fields (RFC-009 Phase 1 allowlist) so the rest
|
||||
/// of the JSON must match exactly. Key-based, recursive; both arms get the
|
||||
/// same placeholders. Everything NOT listed here is contract.
|
||||
pub fn scrub_volatile(value: &mut serde_json::Value) {
|
||||
const VOLATILE_KEYS: &[&str] = &[
|
||||
// identity-bearing per-instance values
|
||||
"commit_id", "id", "parent_id", "merge_parent_id", "snapshot",
|
||||
// wall-clock
|
||||
"committed_at", "created_at", "timestamp",
|
||||
// transport / location
|
||||
"uri", "path",
|
||||
];
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for (key, val) in map.iter_mut() {
|
||||
if VOLATILE_KEYS.contains(&key.as_str()) && !val.is_null() {
|
||||
*val = serde_json::Value::String(format!("<volatile:{key}>"));
|
||||
} else {
|
||||
scrub_volatile(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
scrub_volatile(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub const PARITY_ACTOR: &str = "act-parity";
|
||||
pub const PARITY_TOKEN: &str = "parity-tok";
|
||||
|
||||
/// Identical Cedar bundle for BOTH arms — like-for-like enforcement is part
|
||||
/// of the parity contract (a bare local arm is permissive while a
|
||||
/// tokens-only server is default-deny; comparing those would measure
|
||||
/// configuration, not the fork).
|
||||
pub fn parity_policy_yaml() -> String {
|
||||
r#"version: 1
|
||||
groups:
|
||||
parity: ["act-parity"]
|
||||
protected_branches: []
|
||||
rules:
|
||||
- id: reads
|
||||
allow:
|
||||
actors: { group: parity }
|
||||
actions: [read, export, invoke_query]
|
||||
- id: read-scope
|
||||
allow:
|
||||
actors: { group: parity }
|
||||
actions: [read, export]
|
||||
branch_scope: any
|
||||
- id: writes
|
||||
allow:
|
||||
actors: { group: parity }
|
||||
actions: [change]
|
||||
branch_scope: any
|
||||
- id: branching
|
||||
allow:
|
||||
actors: { group: parity }
|
||||
actions: [schema_apply, branch_create, branch_delete, branch_merge]
|
||||
target_branch_scope: any
|
||||
"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The graph id the parity cluster serves the remote arm under. The
|
||||
/// remote arm addresses it with `--graph PARITY_GRAPH_ID` (RFC-011: the
|
||||
/// server is cluster-only, so a graph selector is required).
|
||||
pub const PARITY_GRAPH_ID: &str = "parity";
|
||||
|
||||
/// Build the remote arm's configuration (RFC-011 cluster-only server).
|
||||
///
|
||||
/// The remote arm is served from a converged cluster directory whose single
|
||||
/// graph (id `parity`) carries the parity Cedar bundle (bound to the graph
|
||||
/// scope). The cluster's derived graph root (`<dir>/graphs/parity.omni`) is
|
||||
/// seeded with the SAME fixture data as the local twin so the two arms compare
|
||||
/// like-for-like. The local (`--store`) arm carries no Cedar policy (RFC-011),
|
||||
/// which is fine because the parity bundle is permissive for `act-parity`.
|
||||
///
|
||||
/// `local_graph` is overwritten with a byte-for-byte copy of the cluster's
|
||||
/// seeded served graph so identity-bearing values that are NOT scrubbed
|
||||
/// (e.g. `graph_commit_id`, edge `id`s in export) match across the arms —
|
||||
/// the served graph is the source of truth and the local twin mirrors it.
|
||||
///
|
||||
/// Returns the `cluster_dir`. The caller spawns the server with `--cluster`.
|
||||
pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> PathBuf {
|
||||
let policy = root.join("parity.policy.yaml");
|
||||
fs::write(&policy, parity_policy_yaml()).unwrap();
|
||||
|
||||
// Remote arm: a cluster directory the server boots from. One graph
|
||||
// (`parity`), schema = the shared fixture, policy bound to the graph.
|
||||
let cluster_dir = root.join("parity-cluster");
|
||||
fs::create_dir_all(&cluster_dir).unwrap();
|
||||
fs::copy(fixture("test.pg"), cluster_dir.join("parity.pg")).unwrap();
|
||||
fs::copy(&policy, cluster_dir.join("parity.policy.yaml")).unwrap();
|
||||
fs::write(
|
||||
cluster_dir.join("cluster.yaml"),
|
||||
format!(
|
||||
r#"version: 1
|
||||
metadata:
|
||||
name: parity
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
{PARITY_GRAPH_ID}:
|
||||
schema: ./parity.pg
|
||||
policies:
|
||||
parity:
|
||||
file: ./parity.policy.yaml
|
||||
applies_to: [{PARITY_GRAPH_ID}]
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Converge the cluster (creates the empty graph at the derived root),
|
||||
// then seed it with the same fixture data the local twin holds.
|
||||
output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("import")
|
||||
.arg("--config")
|
||||
.arg(&cluster_dir),
|
||||
);
|
||||
output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("apply")
|
||||
.arg("--config")
|
||||
.arg(&cluster_dir),
|
||||
);
|
||||
let served_root = cluster_dir
|
||||
.join("graphs")
|
||||
.join(format!("{PARITY_GRAPH_ID}.omni"));
|
||||
output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--data")
|
||||
.arg(fixture("test.jsonl"))
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg(&served_root),
|
||||
);
|
||||
|
||||
// Mirror the seeded served graph into the local twin so both arms hold
|
||||
// identical ULIDs / commit ids (the served graph is authoritative).
|
||||
if local_graph.exists() {
|
||||
fs::remove_dir_all(local_graph).unwrap();
|
||||
}
|
||||
copy_dir(&served_root, local_graph);
|
||||
|
||||
cluster_dir
|
||||
}
|
||||
|
||||
/// Run one CLI invocation per arm with identical verb args: locally against
|
||||
/// `local_graph` (--as actor) and remotely against a server URL whose token
|
||||
/// resolves to the same actor. Returns raw Outputs for exit-code + JSON
|
||||
/// comparison by the caller.
|
||||
pub fn run_both(
|
||||
local_graph: &Path,
|
||||
server_url: &str,
|
||||
args: &[&str],
|
||||
) -> (std::process::Output, std::process::Output) {
|
||||
// Address both arms with GLOBAL flags (`--store` / `--server`) appended after
|
||||
// the verb + its args, so the address is placed correctly regardless of
|
||||
// subcommand nesting (a positional graph only works for top-level verbs;
|
||||
// `schema show <graph>` etc. need the global flag). Local = embedded store,
|
||||
// remote = served. RFC-011: a direct (`--store`) write carries no Cedar
|
||||
// policy — the parity policy is permissive for `act-parity` on the served
|
||||
// arm, so the two arms still agree.
|
||||
let mut local = cli();
|
||||
local
|
||||
.args(args)
|
||||
.arg("--store")
|
||||
.arg(local_graph)
|
||||
.arg("--as")
|
||||
.arg(PARITY_ACTOR);
|
||||
let local_out = local.output().unwrap();
|
||||
|
||||
let mut remote = cli();
|
||||
remote
|
||||
.env("OMNIGRAPH_BEARER_TOKEN", PARITY_TOKEN)
|
||||
.args(args)
|
||||
.arg("--server")
|
||||
.arg(server_url)
|
||||
// RFC-011: the parity server is cluster-only (multi-graph), so the
|
||||
// remote arm must name the graph it addresses.
|
||||
.arg("--graph")
|
||||
.arg(PARITY_GRAPH_ID);
|
||||
let remote_out = remote.output().unwrap();
|
||||
(local_out, remote_out)
|
||||
}
|
||||
|
||||
/// Parse, scrub, and pretty-print for diffable assertion messages.
|
||||
pub fn scrubbed_json(output: &std::process::Output) -> String {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||
.unwrap_or_else(|e| panic!("non-JSON stdout ({e}): {output:?}"));
|
||||
scrub_volatile(&mut value);
|
||||
serde_json::to_string_pretty(&value).unwrap()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,35 +0,0 @@
|
|||
[package]
|
||||
name = "omnigraph-cluster"
|
||||
version = "0.7.2"
|
||||
edition = "2024"
|
||||
description = "Cluster configuration validation, planning, and config-only apply for Omnigraph."
|
||||
license = "MIT"
|
||||
repository = "https://github.com/ModernRelay/omnigraph"
|
||||
homepage = "https://github.com/ModernRelay/omnigraph"
|
||||
documentation = "https://docs.rs/omnigraph-cluster"
|
||||
|
||||
[features]
|
||||
# Fault-injection hooks for the apply protocol (crash-mid-apply, CAS-race
|
||||
# tests), including cluster/engine boundary failures.
|
||||
failpoints = ["dep:fail", "fail/failpoints", "omnigraph/failpoints"]
|
||||
|
||||
[dependencies]
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.2" }
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.7.2" }
|
||||
fail = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
time = { workspace = true }
|
||||
# Runtime handle only — best-effort async lock release in
|
||||
# StateLockGuard::drop on object-store backends (cluster commands always
|
||||
# run inside the caller's tokio runtime).
|
||||
tokio = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
tempfile = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,464 +0,0 @@
|
|||
//! Plan/apply classification: resource diffing, dispositions, approval
|
||||
//! gating, demotion (moved verbatim from lib.rs in the modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn diff_resources(
|
||||
prior: &BTreeMap<String, String>,
|
||||
desired: &BTreeMap<String, String>,
|
||||
) -> Vec<PlanChange> {
|
||||
let mut changes = Vec::new();
|
||||
for (address, after) in desired {
|
||||
match prior.get(address) {
|
||||
None => changes.push(PlanChange {
|
||||
resource: address.clone(),
|
||||
operation: PlanOperation::Create,
|
||||
before_digest: None,
|
||||
after_digest: Some(after.clone()),
|
||||
disposition: None,
|
||||
reason: None,
|
||||
binding_change: false,
|
||||
metadata_change: None,
|
||||
migration: None,
|
||||
}),
|
||||
Some(before) if before != after => changes.push(PlanChange {
|
||||
resource: address.clone(),
|
||||
operation: PlanOperation::Update,
|
||||
before_digest: Some(before.clone()),
|
||||
after_digest: Some(after.clone()),
|
||||
disposition: None,
|
||||
reason: None,
|
||||
binding_change: false,
|
||||
metadata_change: None,
|
||||
migration: None,
|
||||
}),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (address, before) in prior {
|
||||
if !desired.contains_key(address) {
|
||||
changes.push(PlanChange {
|
||||
resource: address.clone(),
|
||||
operation: PlanOperation::Delete,
|
||||
before_digest: Some(before.clone()),
|
||||
after_digest: None,
|
||||
disposition: None,
|
||||
reason: None,
|
||||
binding_change: false,
|
||||
metadata_change: None,
|
||||
migration: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
changes.sort_by(|a, b| a.resource.cmp(&b.resource));
|
||||
changes
|
||||
}
|
||||
|
||||
/// Binding-only policy changes: the file digest is unchanged (so
|
||||
/// `diff_resources` saw nothing) but the applied `applies_to` differs from
|
||||
/// the desired bindings — including the pre-5A case where the state entry
|
||||
/// has no bindings recorded yet. These are first-class plan changes: without
|
||||
/// this pass a binding edit would silently rot or silently converge.
|
||||
pub(crate) fn append_policy_binding_changes(
|
||||
changes: &mut Vec<PlanChange>,
|
||||
prior_state: Option<&ClusterState>,
|
||||
desired: &DesiredCluster,
|
||||
) {
|
||||
let Some(state) = prior_state else {
|
||||
return; // no state: everything is already a Create carrying bindings
|
||||
};
|
||||
for (address, desired_bindings) in &desired.policy_bindings {
|
||||
if changes.iter().any(|change| &change.resource == address) {
|
||||
continue; // content change already covers it
|
||||
}
|
||||
let Some(entry) = state.applied_revision.resources.get(address) else {
|
||||
continue; // not applied yet: the Create covers it
|
||||
};
|
||||
if entry.applies_to.as_ref() == Some(desired_bindings) {
|
||||
continue;
|
||||
}
|
||||
changes.push(PlanChange {
|
||||
resource: address.clone(),
|
||||
operation: PlanOperation::Update,
|
||||
before_digest: Some(entry.digest.clone()),
|
||||
after_digest: Some(entry.digest.clone()),
|
||||
disposition: None,
|
||||
reason: None,
|
||||
binding_change: true,
|
||||
metadata_change: Some(PlanMetadataChange::PolicyBindings),
|
||||
migration: None,
|
||||
});
|
||||
}
|
||||
changes.sort_by(|a, b| a.resource.cmp(&b.resource));
|
||||
}
|
||||
|
||||
/// Metadata-only embedding provider changes: the provider digest is unchanged
|
||||
/// but the applied state predates storing the profile body needed by
|
||||
/// config-free serving. This mirrors policy binding backfill instead of
|
||||
/// hiding a serving-time failure behind a no-op plan.
|
||||
pub(crate) fn append_embedding_profile_changes(
|
||||
changes: &mut Vec<PlanChange>,
|
||||
prior_state: Option<&ClusterState>,
|
||||
desired: &DesiredCluster,
|
||||
) {
|
||||
let Some(state) = prior_state else {
|
||||
return; // no state: provider Creates carry profiles already
|
||||
};
|
||||
for (address, desired_profile) in &desired.embedding_providers {
|
||||
if changes
|
||||
.iter()
|
||||
.any(|change| change.resource.as_str() == address.as_str())
|
||||
{
|
||||
continue; // content change already covers it
|
||||
}
|
||||
let Some(entry) = state.applied_revision.resources.get(address) else {
|
||||
continue; // not applied yet: the Create covers it
|
||||
};
|
||||
if entry.embedding_profile.as_ref() == Some(desired_profile) {
|
||||
continue;
|
||||
}
|
||||
changes.push(PlanChange {
|
||||
resource: address.clone(),
|
||||
operation: PlanOperation::Update,
|
||||
before_digest: Some(entry.digest.clone()),
|
||||
after_digest: Some(entry.digest.clone()),
|
||||
disposition: None,
|
||||
reason: None,
|
||||
binding_change: false,
|
||||
metadata_change: Some(PlanMetadataChange::EmbeddingProfile),
|
||||
migration: None,
|
||||
});
|
||||
}
|
||||
changes.sort_by(|a, b| a.resource.cmp(&b.resource));
|
||||
}
|
||||
|
||||
pub(crate) fn compute_blast_radius(
|
||||
changes: &[PlanChange],
|
||||
dependencies: &[Dependency],
|
||||
) -> Vec<BlastRadius> {
|
||||
changes
|
||||
.iter()
|
||||
.filter_map(|change| {
|
||||
let affected: Vec<_> = dependencies
|
||||
.iter()
|
||||
.filter_map(|dep| (dep.to == change.resource).then_some(dep.from.clone()))
|
||||
.collect();
|
||||
(!affected.is_empty()).then(|| BlastRadius {
|
||||
resource: change.resource.clone(),
|
||||
affected,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn compute_approvals(
|
||||
changes: &[PlanChange],
|
||||
approved: &BTreeSet<String>,
|
||||
) -> Vec<ApprovalRequirement> {
|
||||
// One gate per subtree: the graph.<id> delete carries its schema and
|
||||
// queries, so a schema delete whose graph is also deleted is not listed.
|
||||
let graph_deletes: BTreeSet<String> = changes
|
||||
.iter()
|
||||
.filter(|change| change.operation == PlanOperation::Delete)
|
||||
.filter_map(|change| change.resource.strip_prefix("graph.").map(str::to_string))
|
||||
.collect();
|
||||
changes
|
||||
.iter()
|
||||
.filter_map(|change| {
|
||||
if change.operation != PlanOperation::Delete {
|
||||
return None;
|
||||
}
|
||||
let gated = match resource_kind(&change.resource) {
|
||||
ResourceKind::Graph(_) => true,
|
||||
ResourceKind::Schema(graph) => !graph_deletes.contains(&graph),
|
||||
_ => false,
|
||||
};
|
||||
gated.then(|| ApprovalRequirement {
|
||||
resource: change.resource.clone(),
|
||||
reason: "delete may remove deployed graph or schema definition".to_string(),
|
||||
satisfied: approved.contains(&change.resource),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resources with a valid (digest-matching, unconsumed) pending approval.
|
||||
/// Near-misses — an artifact for the same resource whose bound digests no
|
||||
/// longer match — warn as `approval_stale` and never authorize anything.
|
||||
pub(crate) fn approved_resources(
|
||||
artifacts: &[(String, ApprovalArtifact)],
|
||||
changes: &[PlanChange],
|
||||
config_digest: &str,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> BTreeSet<String> {
|
||||
let mut approved = BTreeSet::new();
|
||||
for change in changes {
|
||||
let candidates: Vec<&ApprovalArtifact> = artifacts
|
||||
.iter()
|
||||
.map(|(_, artifact)| artifact)
|
||||
.filter(|artifact| {
|
||||
artifact.consumed_at.is_none() && artifact.resource == change.resource
|
||||
})
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let matched = candidates.iter().any(|artifact| {
|
||||
artifact.bound_config_digest == config_digest
|
||||
&& artifact.bound_before_digest == change.before_digest
|
||||
&& artifact.bound_after_digest == change.after_digest
|
||||
});
|
||||
if matched {
|
||||
approved.insert(change.resource.clone());
|
||||
} else {
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"approval_stale",
|
||||
change.resource.clone(),
|
||||
"an approval artifact exists but its bound digests no longer match the plan; re-run `cluster approve`",
|
||||
));
|
||||
}
|
||||
}
|
||||
approved
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ResourceKind {
|
||||
Graph(String),
|
||||
Schema(String),
|
||||
Query { graph: String, name: String },
|
||||
Policy(String),
|
||||
EmbeddingProvider(String),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub(crate) fn resource_kind(address: &str) -> ResourceKind {
|
||||
if let Some(graph) = address.strip_prefix("graph.") {
|
||||
ResourceKind::Graph(graph.to_string())
|
||||
} else if let Some(graph) = address.strip_prefix("schema.") {
|
||||
ResourceKind::Schema(graph.to_string())
|
||||
} else if let Some(rest) = address.strip_prefix("query.") {
|
||||
match rest.split_once('.') {
|
||||
Some((graph, name)) => ResourceKind::Query {
|
||||
graph: graph.to_string(),
|
||||
name: name.to_string(),
|
||||
},
|
||||
None => ResourceKind::Unknown,
|
||||
}
|
||||
} else if let Some(name) = address.strip_prefix("policy.") {
|
||||
ResourceKind::Policy(name.to_string())
|
||||
} else if let Some(name) = address.strip_prefix("provider.embedding.") {
|
||||
ResourceKind::EmbeddingProvider(name.to_string())
|
||||
} else {
|
||||
ResourceKind::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify every planned change with the disposition config-only apply gives
|
||||
/// it. Stage 3A executes only query/policy catalog writes; graph/schema
|
||||
/// movement is a later phase, and `graph.<id>` composite updates whose schema
|
||||
/// component is unchanged converge automatically once query digests land.
|
||||
pub(crate) fn classify_changes(
|
||||
changes: &mut [PlanChange],
|
||||
dependencies: &[Dependency],
|
||||
pending_recovery: &BTreeSet<String>,
|
||||
approved: &BTreeSet<String>,
|
||||
) {
|
||||
let mut schema_creates = BTreeSet::new();
|
||||
let mut schema_pending = BTreeSet::new();
|
||||
let mut graph_creates = BTreeSet::new();
|
||||
let mut graph_deletes = BTreeSet::new();
|
||||
for change in changes.iter() {
|
||||
match resource_kind(&change.resource) {
|
||||
ResourceKind::Schema(graph) => match change.operation {
|
||||
PlanOperation::Create => {
|
||||
schema_creates.insert(graph);
|
||||
}
|
||||
// Schema updates execute in-run before catalog writes (4B)
|
||||
// and no longer block dependents; deletes (4C) still do.
|
||||
PlanOperation::Update => {}
|
||||
PlanOperation::Delete => {
|
||||
schema_pending.insert(graph);
|
||||
}
|
||||
},
|
||||
ResourceKind::Graph(graph) => match change.operation {
|
||||
PlanOperation::Create => {
|
||||
graph_creates.insert(graph);
|
||||
}
|
||||
PlanOperation::Delete => {
|
||||
graph_deletes.insert(graph);
|
||||
}
|
||||
PlanOperation::Update => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A schema Create is satisfied by its paired graph create (the init
|
||||
// carries the schema); a standalone schema Create stays pending.
|
||||
for graph in &schema_creates {
|
||||
if !graph_creates.contains(graph) {
|
||||
schema_pending.insert(graph.clone());
|
||||
}
|
||||
}
|
||||
// Subtree deletes ride the approved graph delete.
|
||||
let rides_approved_delete = |graph: &str| {
|
||||
graph_deletes.contains(graph)
|
||||
&& approved.contains(&graph_address(graph))
|
||||
&& !pending_recovery.contains(graph)
|
||||
};
|
||||
|
||||
for change in changes.iter_mut() {
|
||||
let (disposition, reason) = match resource_kind(&change.resource) {
|
||||
ResourceKind::Schema(graph) => match change.operation {
|
||||
PlanOperation::Create
|
||||
if graph_creates.contains(&graph) && !pending_recovery.contains(&graph) =>
|
||||
{
|
||||
// Applied with the graph create — the init carries it.
|
||||
(ApplyDisposition::Applied, None)
|
||||
}
|
||||
PlanOperation::Update if !pending_recovery.contains(&graph) => {
|
||||
// Stage 4B: schema updates execute via the engine's
|
||||
// schema apply (soft drops only; allow_data_loss is 4C).
|
||||
(ApplyDisposition::Applied, None)
|
||||
}
|
||||
PlanOperation::Create | PlanOperation::Update => {
|
||||
(ApplyDisposition::Blocked, Some("cluster_recovery_pending"))
|
||||
}
|
||||
PlanOperation::Delete if graph_deletes.contains(&graph) => {
|
||||
if rides_approved_delete(&graph) {
|
||||
(ApplyDisposition::Applied, None)
|
||||
} else if pending_recovery.contains(&graph) {
|
||||
(ApplyDisposition::Blocked, Some("cluster_recovery_pending"))
|
||||
} else {
|
||||
(ApplyDisposition::Blocked, Some("approval_required"))
|
||||
}
|
||||
}
|
||||
_ => (ApplyDisposition::Deferred, Some("apply_unsupported_kind")),
|
||||
},
|
||||
ResourceKind::Graph(graph) => match change.operation {
|
||||
PlanOperation::Create => {
|
||||
if pending_recovery.contains(&graph) {
|
||||
(ApplyDisposition::Blocked, Some("cluster_recovery_pending"))
|
||||
} else {
|
||||
(ApplyDisposition::Applied, None)
|
||||
}
|
||||
}
|
||||
PlanOperation::Update if !schema_pending.contains(&graph) => {
|
||||
(ApplyDisposition::Derived, None)
|
||||
}
|
||||
// Stage 4C: an approved graph delete executes (the
|
||||
// irreversible tier — gated by a digest-bound artifact).
|
||||
PlanOperation::Delete => {
|
||||
if pending_recovery.contains(&graph) {
|
||||
(ApplyDisposition::Blocked, Some("cluster_recovery_pending"))
|
||||
} else if rides_approved_delete(&graph) {
|
||||
(ApplyDisposition::Applied, None)
|
||||
} else {
|
||||
(ApplyDisposition::Blocked, Some("approval_required"))
|
||||
}
|
||||
}
|
||||
_ => (ApplyDisposition::Deferred, Some("apply_unsupported_kind")),
|
||||
},
|
||||
ResourceKind::Query { graph, .. } => match change.operation {
|
||||
PlanOperation::Delete => {
|
||||
if rides_approved_delete(&graph) {
|
||||
// Tombstoned with the approved graph delete.
|
||||
(ApplyDisposition::Applied, None)
|
||||
} else if graph_deletes.contains(&graph) {
|
||||
(ApplyDisposition::Blocked, Some("approval_required"))
|
||||
} else {
|
||||
(ApplyDisposition::Applied, None)
|
||||
}
|
||||
}
|
||||
PlanOperation::Create | PlanOperation::Update => {
|
||||
if pending_recovery.contains(&graph) {
|
||||
(ApplyDisposition::Blocked, Some("cluster_recovery_pending"))
|
||||
} else if schema_pending.contains(&graph) {
|
||||
(ApplyDisposition::Blocked, Some("dependency_not_applied"))
|
||||
} else {
|
||||
// A graph create in the same plan no longer blocks:
|
||||
// creates execute first in the same apply run.
|
||||
(ApplyDisposition::Applied, None)
|
||||
}
|
||||
}
|
||||
},
|
||||
ResourceKind::Policy(_) => match change.operation {
|
||||
PlanOperation::Delete => (ApplyDisposition::Applied, None),
|
||||
PlanOperation::Create | PlanOperation::Update => {
|
||||
let blocked_pending = dependencies.iter().any(|dep| {
|
||||
dep.from == change.resource
|
||||
&& dep
|
||||
.to
|
||||
.strip_prefix("graph.")
|
||||
.is_some_and(|graph| pending_recovery.contains(graph))
|
||||
});
|
||||
if blocked_pending {
|
||||
(ApplyDisposition::Blocked, Some("cluster_recovery_pending"))
|
||||
} else {
|
||||
(ApplyDisposition::Applied, None)
|
||||
}
|
||||
}
|
||||
},
|
||||
ResourceKind::EmbeddingProvider(_) => (ApplyDisposition::Applied, None),
|
||||
ResourceKind::Unknown => (ApplyDisposition::Deferred, Some("apply_unsupported_kind")),
|
||||
};
|
||||
change.disposition = Some(disposition);
|
||||
change.reason = reason.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FailedGraphOrigin {
|
||||
GraphCreate,
|
||||
SchemaApply,
|
||||
GraphDelete,
|
||||
}
|
||||
|
||||
/// After a graph-moving operation fails mid-run, every change that depended
|
||||
/// on that graph flips from Applied to Blocked so the output and the
|
||||
/// persisted statuses tell the truth about what this run actually executed.
|
||||
/// The originating change carries the failure code; dependents carry
|
||||
/// `dependency_not_applied`.
|
||||
pub(crate) fn demote_dependents_of_failed_graphs(
|
||||
changes: &mut [PlanChange],
|
||||
failed: &BTreeMap<String, FailedGraphOrigin>,
|
||||
dependencies: &[Dependency],
|
||||
) {
|
||||
for change in changes.iter_mut() {
|
||||
if change.disposition != Some(ApplyDisposition::Applied) {
|
||||
continue;
|
||||
}
|
||||
let demote_reason = match resource_kind(&change.resource) {
|
||||
ResourceKind::Graph(graph) => match failed.get(&graph) {
|
||||
Some(FailedGraphOrigin::GraphCreate) => Some("graph_create_failed"),
|
||||
Some(FailedGraphOrigin::GraphDelete) => Some("graph_delete_failed"),
|
||||
Some(FailedGraphOrigin::SchemaApply) => Some("dependency_not_applied"),
|
||||
None => None,
|
||||
},
|
||||
ResourceKind::Schema(graph) => match failed.get(&graph) {
|
||||
Some(FailedGraphOrigin::SchemaApply) => Some("schema_apply_failed"),
|
||||
Some(FailedGraphOrigin::GraphCreate) | Some(FailedGraphOrigin::GraphDelete) => {
|
||||
Some("dependency_not_applied")
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
ResourceKind::Query { graph, .. } if failed.contains_key(&graph) => {
|
||||
Some("dependency_not_applied")
|
||||
}
|
||||
ResourceKind::Policy(_) => {
|
||||
let blocked = dependencies.iter().any(|dep| {
|
||||
dep.from == change.resource
|
||||
&& dep
|
||||
.to
|
||||
.strip_prefix("graph.")
|
||||
.is_some_and(|graph| failed.contains_key(graph))
|
||||
});
|
||||
blocked.then_some("dependency_not_applied")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(reason) = demote_reason {
|
||||
change.disposition = Some(ApplyDisposition::Blocked);
|
||||
change.reason = Some(reason.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
//! Fault-injection hooks for the cluster apply protocol, mirroring the
|
||||
//! engine's `omnigraph::failpoints` pattern. With the `failpoints` feature
|
||||
//! off, every call site compiles to `Ok(())`.
|
||||
//!
|
||||
//! Only `maybe_fail` lives here — it returns the cluster's [`Diagnostic`]
|
||||
//! error type. The test-side configuration guard is shared: use
|
||||
//! [`omnigraph::failpoints::ScopedFailPoint`], which is registry-only
|
||||
//! (error-type agnostic) and reachable because the cluster's `failpoints`
|
||||
//! feature enables `omnigraph/failpoints`. One `ScopedFailPoint`, in the
|
||||
//! lowest crate, avoids a drifting duplicate.
|
||||
|
||||
use crate::Diagnostic;
|
||||
|
||||
pub(crate) fn maybe_fail(_name: &str) -> Result<(), Diagnostic> {
|
||||
#[cfg(feature = "failpoints")]
|
||||
{
|
||||
let name = _name;
|
||||
fail::fail_point!(name, |_| {
|
||||
return Err(Diagnostic::error(
|
||||
"injected_failpoint",
|
||||
name,
|
||||
format!("injected failpoint triggered: {name}"),
|
||||
));
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compile-checked catalog of this crate's apply-protocol failpoint names.
|
||||
/// Engine-scoped names referenced from cluster tests live in
|
||||
/// [`omnigraph::failpoints::names`].
|
||||
pub mod names {
|
||||
pub const CLUSTER_APPLY_AFTER_GRAPH_CREATE: &str = "cluster_apply.after_graph_create";
|
||||
pub const CLUSTER_APPLY_AFTER_GRAPH_DELETE: &str = "cluster_apply.after_graph_delete";
|
||||
pub const CLUSTER_APPLY_AFTER_PAYLOAD_PHASE: &str = "cluster_apply.after_payload_phase";
|
||||
pub const CLUSTER_APPLY_AFTER_SCHEMA_APPLY: &str = "cluster_apply.after_schema_apply";
|
||||
pub const CLUSTER_APPLY_BEFORE_GRAPH_CREATE: &str = "cluster_apply.before_graph_create";
|
||||
pub const CLUSTER_APPLY_BEFORE_GRAPH_DELETE: &str = "cluster_apply.before_graph_delete";
|
||||
pub const CLUSTER_APPLY_BEFORE_SCHEMA_APPLY: &str = "cluster_apply.before_schema_apply";
|
||||
pub const CLUSTER_APPLY_BEFORE_STATE_WRITE: &str = "cluster_apply.before_state_write";
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,478 +0,0 @@
|
|||
//! Phase-5 serving snapshot: the read-only loader a `--cluster` server
|
||||
//! boots from (moved verbatim from lib.rs in the modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// One graph in a serving snapshot: its id and on-disk root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServingGraph {
|
||||
pub graph_id: String,
|
||||
pub root: PathBuf,
|
||||
pub embedding: Option<EmbeddingProviderConfig>,
|
||||
}
|
||||
|
||||
/// One stored query: its graph binding, registry name, and verified source.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServingQuery {
|
||||
pub graph_id: String,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// One policy bundle: its verified catalog blob path and applied bindings
|
||||
/// (normalized typed refs: `cluster` | `graph.<id>`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServingPolicy {
|
||||
pub name: String,
|
||||
/// The policy bundle CONTENT, digest-verified against the applied
|
||||
/// revision at read time. Content, not a path: the catalog may live on
|
||||
/// object storage, and the server must not re-read mutable state.
|
||||
pub source: String,
|
||||
pub applies_to: Vec<String>,
|
||||
}
|
||||
|
||||
/// Everything a server needs to boot from the cluster catalog (RFC-005 §D2).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServingSnapshot {
|
||||
pub graphs: Vec<ServingGraph>,
|
||||
pub queries: Vec<ServingQuery>,
|
||||
pub policies: Vec<ServingPolicy>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Read the applied revision as a serving snapshot — the read-only loader for
|
||||
/// the Phase-5 server boot. Cluster-global readiness failures are still
|
||||
/// all-or-nothing, but graph-attributed pending recovery sidecars quarantine
|
||||
/// only that graph so healthy graphs can continue serving. This loader never
|
||||
/// runs a recovery sweep.
|
||||
/// Takes no lock: the state file is replaced atomically, so this reads a
|
||||
/// consistent point-in-time ledger.
|
||||
pub async fn read_serving_snapshot(
|
||||
config_dir: impl AsRef<Path>,
|
||||
) -> Result<ServingSnapshot, Vec<Diagnostic>> {
|
||||
let config_dir = config_dir.as_ref().to_path_buf();
|
||||
// The declared storage: root decides where the ledger/catalog/graphs
|
||||
// live; config parse errors surface through the normal validation path.
|
||||
let parsed = parse_cluster_config(&config_dir);
|
||||
let storage_root = parsed.raw.as_ref().and_then(|raw| {
|
||||
raw.storage
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|root| !root.is_empty())
|
||||
.map(|root| root.trim_end_matches('/').to_string())
|
||||
});
|
||||
let backend = match storage_root.as_deref() {
|
||||
Some(root) => match ClusterStore::for_storage_root(root) {
|
||||
Ok(backend) => backend,
|
||||
Err(diagnostic) => return Err(vec![diagnostic]),
|
||||
},
|
||||
None => ClusterStore::for_config_dir(&config_dir),
|
||||
};
|
||||
read_snapshot_with_store(backend).await
|
||||
}
|
||||
|
||||
/// Read the applied revision directly from a storage root URI — config-free
|
||||
/// serving: a `--cluster s3://bucket/prefix` server needs no local files at
|
||||
/// all, only the bucket and credentials. The ledger and catalog ARE the
|
||||
/// deployment artifact.
|
||||
pub async fn read_serving_snapshot_from_storage(
|
||||
storage_root: &str,
|
||||
) -> Result<ServingSnapshot, Vec<Diagnostic>> {
|
||||
let backend =
|
||||
ClusterStore::for_storage_root(storage_root).map_err(|diagnostic| vec![diagnostic])?;
|
||||
read_snapshot_with_store(backend).await
|
||||
}
|
||||
|
||||
/// Cluster root for a graph **storage URI** of the cluster layout
|
||||
/// (`<root>/graphs/<id>.omni`), if `<root>` is actually a cluster (holds
|
||||
/// `__cluster/state.json`); otherwise `None`. Used by the CLI to refuse
|
||||
/// `init` into a cluster-managed location — graphs there are created by
|
||||
/// `cluster apply`, not `init`.
|
||||
///
|
||||
/// Cheap by construction: a URI that does not match the `<root>/graphs/<id>.omni`
|
||||
/// shape returns `None` without any I/O, so ordinary `init` targets
|
||||
/// (`./kb.omni`, `s3://bucket/kb.omni`) never probe storage. Works for
|
||||
/// `file://` and `s3://` via the storage adapter.
|
||||
pub async fn cluster_root_for_graph_uri(graph_uri: &str) -> Option<String> {
|
||||
let root = cluster_root_of_graph_layout(graph_uri)?;
|
||||
let store = ClusterStore::for_storage_root(&root).ok()?;
|
||||
store
|
||||
.has_state()
|
||||
.await
|
||||
.then(|| store.display_root().to_string())
|
||||
}
|
||||
|
||||
/// Resolve a graph's **storage URI** (`<root>/graphs/<id>.omni`) from a cluster's
|
||||
/// applied state ledger — the lightweight path for storage-plane maintenance
|
||||
/// (`optimize`/`repair`/`cleanup`).
|
||||
///
|
||||
/// Unlike [`read_serving_snapshot`], this deliberately does NOT validate catalog
|
||||
/// payloads or recovery readiness: maintenance only needs the derivable graph
|
||||
/// root, and must not be blocked by an unrelated corrupt policy/query blob or a
|
||||
/// pending recovery sweep — a degraded cluster is exactly when an operator
|
||||
/// reaches for `repair`. It reads the state ledger, confirms the graph is in the
|
||||
/// applied revision, and returns `graph_root(id)`.
|
||||
///
|
||||
/// `cluster` is a config directory or a storage-root URI (`s3://…`, config-free),
|
||||
/// mirroring the server's `--cluster` dispatch.
|
||||
pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result<String, Diagnostic> {
|
||||
let backend = open_cluster_backend(cluster)?;
|
||||
let mut observations = backend.observations();
|
||||
let snapshot = backend.read_state(&mut observations).await?;
|
||||
let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?;
|
||||
let address = format!("graph.{graph_id}");
|
||||
if !state.applied_revision.resources.contains_key(&address) {
|
||||
let applied = applied_graph_ids(&state);
|
||||
return Err(Diagnostic::error(
|
||||
"graph_not_applied",
|
||||
address,
|
||||
format!(
|
||||
"graph `{graph_id}` is not applied in cluster `{cluster}` (applied graphs: [{}]); \
|
||||
declare it in cluster.yaml and run `cluster apply`, or check the id",
|
||||
applied.join(", ")
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(backend.graph_root(graph_id))
|
||||
}
|
||||
|
||||
/// List the graph ids applied in a cluster's served state (sorted). Reads the
|
||||
/// ledger only — no catalog validation — like `resolve_graph_storage_uri`, so
|
||||
/// it works on a degraded cluster. Used to enumerate candidates when no
|
||||
/// `--graph` is selected (RFC-011 Decision 7).
|
||||
pub async fn cluster_graph_ids(cluster: &str) -> Result<Vec<String>, Diagnostic> {
|
||||
let backend = open_cluster_backend(cluster)?;
|
||||
let mut observations = backend.observations();
|
||||
let snapshot = backend.read_state(&mut observations).await?;
|
||||
let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?;
|
||||
Ok(applied_graph_ids(&state))
|
||||
}
|
||||
|
||||
fn open_cluster_backend(cluster: &str) -> Result<ClusterStore, Diagnostic> {
|
||||
if cluster.contains("://") {
|
||||
ClusterStore::for_storage_root(cluster)
|
||||
} else {
|
||||
Ok(ClusterStore::for_config_dir(Path::new(cluster)))
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_state_diagnostic(cluster: &str) -> Diagnostic {
|
||||
Diagnostic::error(
|
||||
"cluster_state_missing",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("cluster `{cluster}` has no applied state; run `cluster apply` first"),
|
||||
)
|
||||
}
|
||||
|
||||
fn applied_graph_ids(state: &crate::types::ClusterState) -> Vec<String> {
|
||||
let mut ids: Vec<String> = state
|
||||
.applied_revision
|
||||
.resources
|
||||
.keys()
|
||||
.filter_map(|a| a.strip_prefix("graph."))
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Split `<root>/graphs/<id>.omni` → `<root>`, gating on the exact cluster
|
||||
/// graph-layout shape (a single `<id>` segment, no nested path). `None` for
|
||||
/// anything else — no I/O is done for non-cluster-shaped URIs.
|
||||
fn cluster_root_of_graph_layout(graph_uri: &str) -> Option<String> {
|
||||
let trimmed = graph_uri.trim_end_matches('/');
|
||||
let rest = trimmed.strip_suffix(".omni")?;
|
||||
let (root, id) = rest.rsplit_once("/graphs/")?;
|
||||
if root.is_empty() || id.is_empty() || id.contains('/') {
|
||||
return None;
|
||||
}
|
||||
Some(root.to_string())
|
||||
}
|
||||
|
||||
async fn read_snapshot_with_store(
|
||||
backend: ClusterStore,
|
||||
) -> Result<ServingSnapshot, Vec<Diagnostic>> {
|
||||
let mut diagnostics: Vec<Diagnostic> = Vec::new();
|
||||
let mut startup_diagnostics: Vec<Diagnostic> = Vec::new();
|
||||
let mut quarantined_graphs: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
// Do not sweep at serve time. Valid graph-attributed sidecars quarantine
|
||||
// that graph; malformed/unattributable sidecars remain cluster-fatal
|
||||
// because serving cannot prove their blast radius.
|
||||
let sidecar_diag_start = diagnostics.len();
|
||||
let sidecars = backend.list_recovery_sidecars(&mut diagnostics).await;
|
||||
// Every diagnostic `list_recovery_sidecars` appends is a genuine
|
||||
// read/parse/version failure (emitted as a warning by `store::list_json_dir`)
|
||||
// whose blast radius serving cannot prove — promote each to a cluster-fatal
|
||||
// error. This depends on that listing only ever emitting failure diagnostics;
|
||||
// if it grows a benign/informational one, promote by code instead.
|
||||
for diagnostic in diagnostics.iter_mut().skip(sidecar_diag_start) {
|
||||
diagnostic.severity = DiagnosticSeverity::Error;
|
||||
}
|
||||
for (path, sidecar) in sidecars {
|
||||
if sidecar.graph_id.trim().is_empty() {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"cluster_recovery_unattributed",
|
||||
path,
|
||||
"recovery sidecar has no graph id; run a state-mutating cluster command to sweep it before serving",
|
||||
));
|
||||
continue;
|
||||
}
|
||||
quarantined_graphs.insert(sidecar.graph_id.clone());
|
||||
startup_diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_pending",
|
||||
graph_address(&sidecar.graph_id),
|
||||
format!(
|
||||
"graph `{}` is quarantined because interrupted operation `{}` awaits recovery; run any state-mutating cluster command (e.g. `cluster apply`) to sweep",
|
||||
sidecar.graph_id, sidecar.operation_id
|
||||
),
|
||||
));
|
||||
}
|
||||
if has_errors(&diagnostics) {
|
||||
return Err(diagnostics);
|
||||
}
|
||||
|
||||
let mut observations = backend.observations();
|
||||
let state = match backend.read_state(&mut observations).await {
|
||||
Ok(snapshot) => match snapshot.state {
|
||||
Some(state) => Some(state),
|
||||
None => {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"cluster_state_missing",
|
||||
CLUSTER_STATE_FILE,
|
||||
"no cluster state ledger; run `cluster import` and `cluster apply` first",
|
||||
));
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(diagnostic) => {
|
||||
diagnostics.push(diagnostic);
|
||||
None
|
||||
}
|
||||
};
|
||||
let Some(state) = state else {
|
||||
diagnostics.extend(startup_diagnostics);
|
||||
return Err(diagnostics);
|
||||
};
|
||||
|
||||
let required_embedding_providers: BTreeSet<String> = state
|
||||
.applied_revision
|
||||
.resources
|
||||
.iter()
|
||||
.filter_map(|(address, entry)| match resource_kind(address) {
|
||||
ResourceKind::Graph(graph_id) if !quarantined_graphs.contains(&graph_id) => {
|
||||
entry.embedding_provider.clone()
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut embedding_profiles: BTreeMap<String, EmbeddingProviderConfig> = BTreeMap::new();
|
||||
for (address, entry) in &state.applied_revision.resources {
|
||||
if !matches!(resource_kind(address), ResourceKind::EmbeddingProvider(_)) {
|
||||
continue;
|
||||
}
|
||||
if !required_embedding_providers.contains(address) {
|
||||
continue;
|
||||
}
|
||||
let Some(profile) = entry.embedding_profile.clone() else {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"embedding_provider_profile_missing",
|
||||
address.clone(),
|
||||
"no applied embedding provider profile recorded; re-run `cluster apply` to backfill",
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let actual_digest = embedding_provider_digest(&profile);
|
||||
if actual_digest != entry.digest {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"embedding_provider_digest_mismatch",
|
||||
address.clone(),
|
||||
format!(
|
||||
"applied embedding provider profile does not match its recorded digest (actual sha256:{actual_digest}); run `cluster refresh` then `cluster apply`, and restart"
|
||||
),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
embedding_profiles.insert(address.clone(), profile);
|
||||
}
|
||||
|
||||
let mut graphs = Vec::new();
|
||||
let mut queries = Vec::new();
|
||||
let mut policies = Vec::new();
|
||||
let mut saw_applied_graph = false;
|
||||
for (address, entry) in &state.applied_revision.resources {
|
||||
match resource_kind(address) {
|
||||
ResourceKind::Graph(graph_id) => {
|
||||
saw_applied_graph = true;
|
||||
if quarantined_graphs.contains(&graph_id) {
|
||||
continue;
|
||||
}
|
||||
let embedding = match entry.embedding_provider.as_deref() {
|
||||
Some(provider_address) => match resource_kind(provider_address) {
|
||||
ResourceKind::EmbeddingProvider(_) => {
|
||||
match embedding_profiles.get(provider_address) {
|
||||
Some(profile) => Some(profile.clone()),
|
||||
None => {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"embedding_provider_missing",
|
||||
address.clone(),
|
||||
format!(
|
||||
"graph references `{provider_address}`, but no applied embedding provider profile is available; re-run `cluster apply`"
|
||||
),
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"wrong_kind_reference",
|
||||
address.clone(),
|
||||
format!(
|
||||
"graph embedding_provider expects `provider.embedding.<name>`, got `{provider_address}`"
|
||||
),
|
||||
));
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
graphs.push(ServingGraph {
|
||||
root: PathBuf::from(backend.graph_root(&graph_id)),
|
||||
graph_id,
|
||||
embedding,
|
||||
});
|
||||
}
|
||||
ResourceKind::Schema(_) => {}
|
||||
kind @ ResourceKind::Query { .. } => {
|
||||
let ResourceKind::Query { graph, name } = &kind else {
|
||||
unreachable!()
|
||||
};
|
||||
if quarantined_graphs.contains(graph) {
|
||||
continue;
|
||||
}
|
||||
match backend
|
||||
.read_verified_payload(&kind, &entry.digest, address)
|
||||
.await
|
||||
{
|
||||
Ok(source) => queries.push(ServingQuery {
|
||||
graph_id: graph.clone(),
|
||||
name: name.clone(),
|
||||
source,
|
||||
}),
|
||||
Err(diagnostic) => diagnostics.push(diagnostic),
|
||||
}
|
||||
}
|
||||
kind @ ResourceKind::Policy(_) => {
|
||||
let ResourceKind::Policy(name) = &kind else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(applies_to) = entry.applies_to.clone() else {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"policy_bindings_missing",
|
||||
address.clone(),
|
||||
"no applied applies_to bindings recorded (ledger predates binding metadata); re-run `cluster apply` to backfill",
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let applies_to: Vec<String> = applies_to
|
||||
.into_iter()
|
||||
.filter(|binding| {
|
||||
binding
|
||||
.strip_prefix("graph.")
|
||||
.is_none_or(|graph| !quarantined_graphs.contains(graph))
|
||||
})
|
||||
.collect();
|
||||
if applies_to.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match backend
|
||||
.read_verified_payload(&kind, &entry.digest, address)
|
||||
.await
|
||||
{
|
||||
Ok(source) => policies.push(ServingPolicy {
|
||||
name: name.clone(),
|
||||
source,
|
||||
applies_to,
|
||||
}),
|
||||
Err(diagnostic) => diagnostics.push(diagnostic),
|
||||
}
|
||||
}
|
||||
ResourceKind::EmbeddingProvider(_) => {}
|
||||
ResourceKind::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
if graphs.is_empty() {
|
||||
if saw_applied_graph && !quarantined_graphs.is_empty() {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"cluster_no_healthy_graphs",
|
||||
CLUSTER_RECOVERIES_DIR,
|
||||
"all applied graphs are quarantined by pending recovery sidecars; run any state-mutating cluster command (e.g. `cluster apply`) to sweep, then retry",
|
||||
));
|
||||
} else {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"cluster_empty",
|
||||
CLUSTER_STATE_FILE,
|
||||
"the applied revision records no graphs; apply a cluster with at least one graph before serving from it",
|
||||
));
|
||||
}
|
||||
}
|
||||
if has_errors(&diagnostics) {
|
||||
diagnostics.extend(startup_diagnostics);
|
||||
return Err(diagnostics);
|
||||
}
|
||||
Ok(ServingSnapshot {
|
||||
graphs,
|
||||
queries,
|
||||
policies,
|
||||
diagnostics: startup_diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn graph_layout_gating_does_no_io_for_non_cluster_shapes() {
|
||||
// Only `<root>/graphs/<id>.omni` matches; everything else is None.
|
||||
assert_eq!(
|
||||
cluster_root_of_graph_layout("/data/cluster/graphs/kb.omni").as_deref(),
|
||||
Some("/data/cluster")
|
||||
);
|
||||
assert_eq!(
|
||||
cluster_root_of_graph_layout("s3://bucket/prefix/graphs/kb.omni").as_deref(),
|
||||
Some("s3://bucket/prefix")
|
||||
);
|
||||
assert_eq!(cluster_root_of_graph_layout("./kb.omni"), None);
|
||||
assert_eq!(cluster_root_of_graph_layout("s3://bucket/kb.omni"), None);
|
||||
// nested id under graphs/ is not the cluster layout
|
||||
assert_eq!(cluster_root_of_graph_layout("/c/graphs/a/b.omni"), None);
|
||||
// not a .omni graph
|
||||
assert_eq!(cluster_root_of_graph_layout("/c/graphs/kb"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_root_detected_only_when_state_ledger_present() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
std::fs::create_dir_all(root.join("graphs")).unwrap();
|
||||
let graph_uri = format!("{}/graphs/kb.omni", root.to_string_lossy());
|
||||
|
||||
// No __cluster/state.json yet → not a cluster.
|
||||
assert_eq!(cluster_root_for_graph_uri(&graph_uri).await, None);
|
||||
|
||||
// Lay down the state ledger → now it's a cluster-managed location.
|
||||
std::fs::create_dir_all(root.join("__cluster")).unwrap();
|
||||
std::fs::write(root.join(CLUSTER_STATE_FILE), "{}").unwrap();
|
||||
let detected = cluster_root_for_graph_uri(&graph_uri).await;
|
||||
assert!(detected.is_some(), "expected cluster root to be detected");
|
||||
|
||||
// A non-cluster-shaped target never probes and is always None.
|
||||
assert_eq!(
|
||||
cluster_root_for_graph_uri(&format!("{}/plain.omni", root.to_string_lossy())).await,
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,842 +0,0 @@
|
|||
//! The cluster's storage layer: every stored byte (state ledger, lock,
|
||||
//! recovery sidecars, approval artifacts, catalog payloads) goes through the
|
||||
//! engine's `StorageAdapter`, so `file://` and `s3://` are one code path
|
||||
//! (RFC-006). Declared configuration — `cluster.yaml` and the schema/query/
|
||||
//! policy sources it references — deliberately does NOT live here: config is
|
||||
//! read from the operator's working tree (Terraform's config-local /
|
||||
//! state-remote split).
|
||||
//!
|
||||
//! Raw `fs::*` for cluster state outside this module is a deny-list entry.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process;
|
||||
use std::sync::Arc;
|
||||
|
||||
use omnigraph::storage::{StorageAdapter, StorageKind, storage_for_uri, storage_kind_for_uri};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{
|
||||
ApprovalArtifact, CLUSTER_APPROVALS_DIR, CLUSTER_LOCK_FILE, CLUSTER_RECOVERIES_DIR,
|
||||
CLUSTER_RESOURCES_DIR, CLUSTER_STATE_FILE, ClusterState, Diagnostic, RecoverySidecar,
|
||||
ResourceKind, StateLockFile, StateObservations, sha256_hex,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ClusterStore {
|
||||
adapter: Arc<dyn StorageAdapter>,
|
||||
/// Normalized storage-root URI, no trailing slash: `file:///abs/dir`
|
||||
/// (the default config-dir layout) or `s3://bucket/prefix`.
|
||||
root: String,
|
||||
/// What observations/diagnostics display for stored locations: the plain
|
||||
/// local path for `file://` roots (byte-compatible with the pre-store
|
||||
/// outputs), the URI otherwise.
|
||||
display_root: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StateSnapshot {
|
||||
pub(crate) state: Option<ClusterState>,
|
||||
/// Content identity (`sha256:<hex>`) — the public CAS vocabulary.
|
||||
pub(crate) state_cas: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StateLockGuard {
|
||||
adapter: Arc<dyn StorageAdapter>,
|
||||
uri: String,
|
||||
kind: StorageKind,
|
||||
}
|
||||
|
||||
impl Drop for StateLockGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.kind {
|
||||
// Deterministic release on the file backend (tests assert the
|
||||
// lock is gone the moment a command returns).
|
||||
StorageKind::Local => {
|
||||
let path = self.uri.trim_start_matches("file://");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
// Object stores need an async delete, and it must COMPLETE
|
||||
// before a short-lived CLI process exits — a spawned task dies
|
||||
// with the runtime and leaks the lock (caught by the s3 smoke
|
||||
// test: import's lock survived into the next command). On the
|
||||
// multi-thread runtime (the CLI and the gated s3 tests),
|
||||
// block_in_place waits for the delete; on a current-thread
|
||||
// runtime that's not allowed, so fall back to a spawn —
|
||||
// best-effort, with `force-unlock` as the documented recovery,
|
||||
// same as a crash.
|
||||
StorageKind::S3 => {
|
||||
let adapter = Arc::clone(&self.adapter);
|
||||
let uri = self.uri.clone();
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread {
|
||||
tokio::task::block_in_place(move || {
|
||||
handle.block_on(async move {
|
||||
let _ = adapter.delete(&uri).await;
|
||||
});
|
||||
});
|
||||
} else {
|
||||
handle.spawn(async move {
|
||||
let _ = adapter.delete(&uri).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClusterStore {
|
||||
/// The default layout: storage root = the config directory itself
|
||||
/// (`file://<abs config dir>`), byte-compatible with every pre-existing
|
||||
/// cluster on disk.
|
||||
pub(crate) fn for_config_dir(config_dir: &Path) -> Self {
|
||||
let absolute =
|
||||
std::path::absolute(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
|
||||
let display_root = absolute
|
||||
.to_string_lossy()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let root = format!("file://{display_root}");
|
||||
let adapter = storage_for_uri(&root)
|
||||
.expect("local storage adapter construction is infallible for file:// roots");
|
||||
Self {
|
||||
adapter,
|
||||
root,
|
||||
display_root,
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit `storage:` root. `file://` URIs and plain paths normalize
|
||||
/// to the local backend; `s3://bucket/prefix` to the S3 backend (env-
|
||||
/// driven credentials/endpoint — the same contract as graph storage).
|
||||
pub(crate) fn for_storage_root(root_uri: &str) -> Result<Self, Diagnostic> {
|
||||
let trimmed = root_uri.trim_end_matches('/');
|
||||
if storage_kind_for_uri(trimmed) == StorageKind::Local {
|
||||
let path = trimmed.trim_start_matches("file://");
|
||||
return Ok(Self::for_config_dir(Path::new(path)));
|
||||
}
|
||||
let adapter = storage_for_uri(trimmed).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"storage_root_invalid",
|
||||
"storage",
|
||||
format!("could not initialize storage for '{root_uri}': {err}"),
|
||||
)
|
||||
})?;
|
||||
Ok(Self {
|
||||
adapter,
|
||||
root: trimmed.to_string(),
|
||||
display_root: trimmed.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn kind(&self) -> StorageKind {
|
||||
storage_kind_for_uri(&self.root)
|
||||
}
|
||||
|
||||
fn uri(&self, relative: &str) -> String {
|
||||
format!("{}/{}", self.root, relative)
|
||||
}
|
||||
|
||||
fn display(&self, relative: &str) -> String {
|
||||
format!("{}/{}", self.display_root, relative)
|
||||
}
|
||||
|
||||
/// Derived graph root for `<id>`: `<storage>/graphs/<id>.omni`. A plain
|
||||
/// local path for `file://` roots (byte-compatible, directly usable by
|
||||
/// the engine); the S3 URI the engine opens natively otherwise.
|
||||
pub(crate) fn graph_root(&self, graph_id: &str) -> String {
|
||||
match self.kind() {
|
||||
StorageKind::Local => format!("{}/graphs/{graph_id}.omni", self.display_root),
|
||||
StorageKind::S3 => format!("{}/graphs/{graph_id}.omni", self.root),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display-form storage root (plain local path for `file://`, URI for S3).
|
||||
pub(crate) fn display_root(&self) -> &str {
|
||||
&self.display_root
|
||||
}
|
||||
|
||||
/// Whether this root holds the cluster state ledger (`__cluster/state.json`)
|
||||
/// — i.e. is an actual cluster, not just any directory. Probed via the
|
||||
/// adapter (`file://` or `s3://`), failures read as "not a cluster".
|
||||
pub(crate) async fn has_state(&self) -> bool {
|
||||
self.adapter
|
||||
.exists(&self.uri(CLUSTER_STATE_FILE))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `read_text_versioned`, returning None for a missing object (probed
|
||||
/// via `exists` — the engine error type doesn't discriminate NotFound).
|
||||
async fn read_versioned_opt(&self, uri: &str) -> Result<Option<(String, String)>, String> {
|
||||
match self.adapter.exists(uri).await {
|
||||
Ok(false) => return Ok(None),
|
||||
Ok(true) => {}
|
||||
Err(err) => return Err(err.to_string()),
|
||||
}
|
||||
self.adapter
|
||||
.read_text_versioned(uri)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// JSON object write. Atomic visibility is the storage adapter's
|
||||
/// contract on every backend (staged temp + rename on the filesystem,
|
||||
/// a single atomic PUT on object stores) — no torn JSON after a crash,
|
||||
/// no per-backend branch needed here.
|
||||
async fn put_json(&self, relative: &str, payload: &str) -> Result<(), String> {
|
||||
let target = self.uri(relative);
|
||||
self.adapter
|
||||
.write_text(&target, payload)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Shared list-and-parse for the sidecar/approval directories: id
|
||||
/// (filename) order; unparseable objects warn and stay for the operator.
|
||||
async fn list_json_dir<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
dir: &str,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
list_error_code: &'static str,
|
||||
parse_error_code: &'static str,
|
||||
version_ok: impl Fn(&T) -> bool,
|
||||
version_error_code: &'static str,
|
||||
) -> Vec<(String, T)> {
|
||||
let dir_uri = self.uri(dir);
|
||||
let mut uris = match self.adapter.list_dir(&dir_uri).await {
|
||||
Ok(uris) => uris,
|
||||
Err(err) => {
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
list_error_code,
|
||||
dir,
|
||||
format!("could not list '{dir}': {err}"),
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
uris.retain(|uri| uri.ends_with(".json"));
|
||||
uris.sort();
|
||||
let mut out = Vec::new();
|
||||
for uri in uris {
|
||||
match self.adapter.read_text(&uri).await {
|
||||
Ok(text) => match serde_json::from_str::<T>(&text) {
|
||||
Ok(value) if version_ok(&value) => out.push((uri, value)),
|
||||
Ok(_) => diagnostics.push(Diagnostic::warning(
|
||||
version_error_code,
|
||||
uri.clone(),
|
||||
"unsupported schema version; leaving it in place".to_string(),
|
||||
)),
|
||||
Err(err) => diagnostics.push(Diagnostic::warning(
|
||||
parse_error_code,
|
||||
uri.clone(),
|
||||
format!("could not parse ({err}); leaving it in place"),
|
||||
)),
|
||||
},
|
||||
Err(err) => diagnostics.push(Diagnostic::warning(
|
||||
parse_error_code,
|
||||
uri.clone(),
|
||||
format!("could not read ({err}); leaving it in place"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Best-effort object removal (sidecar retirement after a CAS lands,
|
||||
/// lock cleanup) — failures are recoverable by the next sweep.
|
||||
pub(crate) async fn delete_object(&self, uri: &str) {
|
||||
let _ = self.try_delete_object(uri).await;
|
||||
}
|
||||
|
||||
/// Like `delete_object` but surfaces the failure, so a caller that depends
|
||||
/// on the deletion (e.g. the pre-movement sidecar cleanup fast-path) can
|
||||
/// report it as a diagnostic instead of silently leaving stale state.
|
||||
pub(crate) async fn try_delete_object(&self, uri: &str) -> Result<(), String> {
|
||||
self.adapter.delete(uri).await.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Recursive prefix delete for graph roots (approved deletes). Idempotent;
|
||||
/// S3 non-atomicity is tolerated by the delete protocol's retry shape.
|
||||
pub(crate) async fn delete_graph_root(&self, graph_uri: &str) -> Result<(), String> {
|
||||
self.adapter
|
||||
.delete_prefix(graph_uri)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Existence probe for graph roots in sweep classification. A bare local
|
||||
/// path or any URI works — resolved through the same adapter machinery
|
||||
/// the engine uses.
|
||||
pub(crate) async fn graph_root_exists(&self, graph_uri: &str) -> bool {
|
||||
match storage_kind_for_uri(graph_uri) {
|
||||
StorageKind::Local => Path::new(graph_uri.trim_start_matches("file://")).exists(),
|
||||
StorageKind::S3 => match storage_for_uri(graph_uri) {
|
||||
Ok(adapter) => !adapter
|
||||
.list_dir(graph_uri)
|
||||
.await
|
||||
.map(|entries| entries.is_empty())
|
||||
.unwrap_or(true),
|
||||
Err(_) => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---- approvals ----
|
||||
|
||||
pub(crate) async fn list_approval_artifacts(
|
||||
&self,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> Vec<(String, ApprovalArtifact)> {
|
||||
self.list_json_dir(
|
||||
CLUSTER_APPROVALS_DIR,
|
||||
diagnostics,
|
||||
"approval_read_error",
|
||||
"invalid_approval_artifact",
|
||||
|artifact: &ApprovalArtifact| artifact.schema_version == 1,
|
||||
"unsupported_approval_version",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn write_approval_artifact(
|
||||
&self,
|
||||
artifact: &ApprovalArtifact,
|
||||
) -> Result<String, Diagnostic> {
|
||||
let relative = format!("{CLUSTER_APPROVALS_DIR}/{}.json", artifact.approval_id);
|
||||
let mut payload = serde_json::to_string_pretty(artifact).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"approval_write_error",
|
||||
self.display(&relative),
|
||||
format!("could not encode approval artifact: {err}"),
|
||||
)
|
||||
})?;
|
||||
payload.push('\n');
|
||||
self.put_json(&relative, &payload).await.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"approval_write_error",
|
||||
self.display(&relative),
|
||||
format!("could not write approval artifact: {err}"),
|
||||
)
|
||||
})?;
|
||||
Ok(self.uri(&relative))
|
||||
}
|
||||
|
||||
// ---- recovery sidecars ----
|
||||
|
||||
pub(crate) async fn list_recovery_sidecar_locations(
|
||||
&self,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> Vec<String> {
|
||||
let dir_uri = self.uri(CLUSTER_RECOVERIES_DIR);
|
||||
let mut uris = match self.adapter.list_dir(&dir_uri).await {
|
||||
Ok(uris) => uris,
|
||||
Err(err) => {
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"recovery_sidecar_read_error",
|
||||
CLUSTER_RECOVERIES_DIR,
|
||||
format!("could not list '{CLUSTER_RECOVERIES_DIR}': {err}"),
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
uris.retain(|uri| uri.ends_with(".json"));
|
||||
uris.sort();
|
||||
uris.into_iter()
|
||||
.map(|uri| {
|
||||
let name = uri.rsplit_once('/').map_or(uri.as_str(), |(_, name)| name);
|
||||
format!("{}/{name}", self.display(CLUSTER_RECOVERIES_DIR))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn list_recovery_sidecars(
|
||||
&self,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> Vec<(String, RecoverySidecar)> {
|
||||
self.list_json_dir(
|
||||
CLUSTER_RECOVERIES_DIR,
|
||||
diagnostics,
|
||||
"recovery_sidecar_read_error",
|
||||
"invalid_recovery_sidecar",
|
||||
|sidecar: &RecoverySidecar| sidecar.schema_version == 1,
|
||||
"unsupported_recovery_sidecar_version",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn write_recovery_sidecar(
|
||||
&self,
|
||||
sidecar: &RecoverySidecar,
|
||||
) -> Result<String, Diagnostic> {
|
||||
let relative = format!("{CLUSTER_RECOVERIES_DIR}/{}.json", sidecar.operation_id);
|
||||
let mut payload = serde_json::to_string_pretty(sidecar).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"recovery_sidecar_write_error",
|
||||
self.display(&relative),
|
||||
format!("could not encode recovery sidecar: {err}"),
|
||||
)
|
||||
})?;
|
||||
payload.push('\n');
|
||||
self.put_json(&relative, &payload).await.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"recovery_sidecar_write_error",
|
||||
self.display(&relative),
|
||||
format!("could not write recovery sidecar: {err}"),
|
||||
)
|
||||
})?;
|
||||
Ok(self.uri(&relative))
|
||||
}
|
||||
|
||||
// ---- catalog payloads ----
|
||||
|
||||
/// Content-addressed catalog location for a query/policy payload
|
||||
/// (extensions fixed per kind, same as the pre-port layout).
|
||||
pub(crate) fn payload_relative(kind: &ResourceKind, digest: &str) -> Option<String> {
|
||||
match kind {
|
||||
ResourceKind::Query { graph, name } => Some(format!(
|
||||
"{CLUSTER_RESOURCES_DIR}/query/{graph}/{name}/{digest}.gq"
|
||||
)),
|
||||
ResourceKind::Policy(name) => Some(format!(
|
||||
"{CLUSTER_RESOURCES_DIR}/policy/{name}/{digest}.yaml"
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn payload_exists(&self, kind: &ResourceKind, digest: &str) -> bool {
|
||||
let Some(relative) = Self::payload_relative(kind, digest) else {
|
||||
return false;
|
||||
};
|
||||
self.adapter
|
||||
.exists(&self.uri(&relative))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Raw payload read: `Ok(None)` for a missing blob, `Err` for transport
|
||||
/// failures — callers classify (verify loops need the three-way split).
|
||||
pub(crate) async fn read_payload(
|
||||
&self,
|
||||
kind: &ResourceKind,
|
||||
digest: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(relative) = Self::payload_relative(kind, digest) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let uri = self.uri(&relative);
|
||||
match self.adapter.exists(&uri).await {
|
||||
Ok(false) => return Ok(None),
|
||||
Ok(true) => {}
|
||||
Err(err) => return Err(err.to_string()),
|
||||
}
|
||||
self.adapter
|
||||
.read_text(&uri)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"could not read catalog payload '{}': {err}",
|
||||
self.display(&relative)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Idempotent content-addressed write: a payload already present at its
|
||||
/// digest is by definition identical.
|
||||
pub(crate) async fn write_payload(
|
||||
&self,
|
||||
kind: &ResourceKind,
|
||||
digest: &str,
|
||||
content: &str,
|
||||
) -> Result<(), String> {
|
||||
let Some(relative) = Self::payload_relative(kind, digest) else {
|
||||
return Err("resource kind has no payload".to_string());
|
||||
};
|
||||
if self
|
||||
.adapter
|
||||
.exists(&self.uri(&relative))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.put_json(&relative, content).await
|
||||
}
|
||||
|
||||
/// Read a catalog payload and verify it against its recorded digest.
|
||||
pub(crate) async fn read_verified_payload(
|
||||
&self,
|
||||
kind: &ResourceKind,
|
||||
digest: &str,
|
||||
address: &str,
|
||||
) -> Result<String, Diagnostic> {
|
||||
let Some(relative) = Self::payload_relative(kind, digest) else {
|
||||
return Err(Diagnostic::error(
|
||||
"catalog_payload_missing",
|
||||
address,
|
||||
"resource kind has no payload",
|
||||
));
|
||||
};
|
||||
let uri = self.uri(&relative);
|
||||
let text = self.adapter.read_text(&uri).await.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"catalog_payload_missing",
|
||||
address,
|
||||
format!(
|
||||
"catalog blob '{}' unreadable ({err}); run `cluster refresh` then `cluster apply`, and restart",
|
||||
self.display(&relative)
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if sha256_hex(text.as_bytes()) != digest {
|
||||
return Err(Diagnostic::error(
|
||||
"catalog_payload_digest_mismatch",
|
||||
address,
|
||||
format!(
|
||||
"catalog blob '{}' does not match its recorded digest; run `cluster refresh` then `cluster apply`, and restart",
|
||||
self.display(&relative)
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
// ---- observations ----
|
||||
|
||||
pub(crate) fn observations(&self) -> StateObservations {
|
||||
StateObservations {
|
||||
state_path: self.display(CLUSTER_STATE_FILE),
|
||||
lock_path: self.display(CLUSTER_LOCK_FILE),
|
||||
state_found: false,
|
||||
applied_config_digest: None,
|
||||
state_revision: 0,
|
||||
state_cas: None,
|
||||
resource_count: 0,
|
||||
locked: false,
|
||||
lock_id: None,
|
||||
lock_acquired: false,
|
||||
acquired_lock_id: None,
|
||||
lock_operation: None,
|
||||
lock_created_at: None,
|
||||
lock_pid: None,
|
||||
lock_age_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- state ledger ----
|
||||
|
||||
pub(crate) async fn read_state(
|
||||
&self,
|
||||
observations: &mut StateObservations,
|
||||
) -> Result<StateSnapshot, Diagnostic> {
|
||||
let state_uri = self.uri(CLUSTER_STATE_FILE);
|
||||
let (text, _version) = match self.read_versioned_opt(&state_uri).await {
|
||||
Ok(Some(read)) => read,
|
||||
Ok(None) => {
|
||||
return Ok(StateSnapshot {
|
||||
state: None,
|
||||
state_cas: None,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(Diagnostic::error(
|
||||
"state_read_error",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("could not read state file: {err}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
observations.state_found = true;
|
||||
let state_cas = format!("sha256:{}", sha256_hex(text.as_bytes()));
|
||||
observations.state_cas = Some(state_cas.clone());
|
||||
|
||||
let state = serde_json::from_str::<ClusterState>(&text).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"invalid_state_json",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("could not parse state JSON: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if state.version != 1 {
|
||||
return Err(Diagnostic::error(
|
||||
"unsupported_state_version",
|
||||
"state.version",
|
||||
format!(
|
||||
"unsupported cluster state version {}; this build supports version 1",
|
||||
state.version
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
observations.applied_config_digest = state.applied_revision.config_digest.clone();
|
||||
observations.state_revision = state.state_revision;
|
||||
observations.resource_count = state.applied_revision.resources.len();
|
||||
|
||||
Ok(StateSnapshot {
|
||||
state: Some(state),
|
||||
state_cas: Some(state_cas),
|
||||
})
|
||||
}
|
||||
|
||||
/// CAS-guarded ledger replace. The public contract stays content-level
|
||||
/// (`expected_cas` = `sha256:<hex>` from the snapshot the command read);
|
||||
/// the physical swap is token-conditioned on a fresh read, so a writer
|
||||
/// that raced us between the fresh read and the put loses with
|
||||
/// `state_cas_mismatch` — never a silent overwrite. On S3 the token is
|
||||
/// the object's ETag and the put is conditional (If-Match); locally it
|
||||
/// is a content token over the same temp+rename flow as before the port.
|
||||
pub(crate) async fn write_state(
|
||||
&self,
|
||||
state: &ClusterState,
|
||||
expected_cas: Option<&str>,
|
||||
observations: &mut StateObservations,
|
||||
) -> Result<(), Diagnostic> {
|
||||
let state_uri = self.uri(CLUSTER_STATE_FILE);
|
||||
let current = self.read_versioned_opt(&state_uri).await.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"state_write_error",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("could not read state file before write: {err}"),
|
||||
)
|
||||
})?;
|
||||
let current_cas = current
|
||||
.as_ref()
|
||||
.map(|(text, _)| format!("sha256:{}", sha256_hex(text.as_bytes())));
|
||||
if current_cas.as_deref() != expected_cas {
|
||||
return Err(state_cas_mismatch());
|
||||
}
|
||||
|
||||
let mut payload = serde_json::to_string_pretty(state).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"state_write_error",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("could not encode state JSON: {err}"),
|
||||
)
|
||||
})?;
|
||||
payload.push('\n');
|
||||
|
||||
let written = match current {
|
||||
None => self
|
||||
.adapter
|
||||
.write_text_if_absent(&state_uri, &payload)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"state_write_error",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("could not create state.json: {err}"),
|
||||
)
|
||||
})?,
|
||||
Some((_, version)) => self
|
||||
.adapter
|
||||
.write_text_if_match(&state_uri, &payload, &version)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"state_write_error",
|
||||
CLUSTER_STATE_FILE,
|
||||
format!("could not replace state.json: {err}"),
|
||||
)
|
||||
})?
|
||||
.is_some(),
|
||||
};
|
||||
if !written {
|
||||
return Err(state_cas_mismatch());
|
||||
}
|
||||
|
||||
observations.state_found = true;
|
||||
observations.applied_config_digest = state.applied_revision.config_digest.clone();
|
||||
observations.state_revision = state.state_revision;
|
||||
observations.state_cas = Some(format!("sha256:{}", sha256_hex(payload.as_bytes())));
|
||||
observations.resource_count = state.applied_revision.resources.len();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- lock ----
|
||||
|
||||
pub(crate) async fn acquire_lock(
|
||||
&self,
|
||||
operation: &str,
|
||||
observations: &mut StateObservations,
|
||||
) -> Result<StateLockGuard, Diagnostic> {
|
||||
let lock_uri = self.uri(CLUSTER_LOCK_FILE);
|
||||
let lock_id = Ulid::new().to_string();
|
||||
let lock = StateLockFile {
|
||||
version: 1,
|
||||
lock_id: lock_id.clone(),
|
||||
operation: operation.to_string(),
|
||||
created_at: OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
||||
pid: process::id(),
|
||||
};
|
||||
let payload = serde_json::to_string_pretty(&lock).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"state_lock_error",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not encode state lock: {err}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
match self.adapter.write_text_if_absent(&lock_uri, &payload).await {
|
||||
Ok(true) => {
|
||||
observations.lock_acquired = true;
|
||||
observations.acquired_lock_id = Some(lock_id);
|
||||
Ok(StateLockGuard {
|
||||
adapter: Arc::clone(&self.adapter),
|
||||
uri: lock_uri,
|
||||
kind: self.kind(),
|
||||
})
|
||||
}
|
||||
Ok(false) => {
|
||||
self.observe_lock_metadata_lossy(observations).await;
|
||||
Err(Diagnostic::error(
|
||||
"state_lock_held",
|
||||
CLUSTER_LOCK_FILE,
|
||||
state_lock_held_message(observations),
|
||||
))
|
||||
}
|
||||
Err(err) => Err(Diagnostic::error(
|
||||
"state_lock_error",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not write state lock: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn force_unlock(
|
||||
&self,
|
||||
lock_id: &str,
|
||||
observations: &mut StateObservations,
|
||||
) -> Result<(), Diagnostic> {
|
||||
let lock_uri = self.uri(CLUSTER_LOCK_FILE);
|
||||
let text = match self.read_versioned_opt(&lock_uri).await {
|
||||
Ok(Some((text, _))) => text,
|
||||
Ok(None) => {
|
||||
return Err(Diagnostic::error(
|
||||
"state_lock_missing",
|
||||
CLUSTER_LOCK_FILE,
|
||||
"no cluster state lock is present",
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(Diagnostic::error(
|
||||
"state_lock_read_error",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not read state lock: {err}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let lock = parse_lock_file_for_unlock(&text)?;
|
||||
observations.observe_lock_metadata(&lock);
|
||||
observations.locked = true;
|
||||
if lock.lock_id != lock_id {
|
||||
return Err(Diagnostic::error(
|
||||
"state_lock_id_mismatch",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!(
|
||||
"lock id mismatch: held lock is {}, refusing to remove (pass the exact id from `cluster status`)",
|
||||
lock.lock_id
|
||||
),
|
||||
));
|
||||
}
|
||||
self.adapter.delete(&lock_uri).await.map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"state_lock_error",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not remove state lock: {err}"),
|
||||
)
|
||||
})?;
|
||||
observations.locked = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn observe_lock(
|
||||
&self,
|
||||
observations: &mut StateObservations,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) {
|
||||
let lock_uri = self.uri(CLUSTER_LOCK_FILE);
|
||||
match self.read_versioned_opt(&lock_uri).await {
|
||||
Ok(Some((text, _))) => {
|
||||
observations.locked = true;
|
||||
match serde_json::from_str::<StateLockFile>(&text) {
|
||||
Ok(lock) if lock.version == 1 => observations.observe_lock_metadata(&lock),
|
||||
Ok(lock) => diagnostics.push(Diagnostic::warning(
|
||||
"unsupported_state_lock_version",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("unsupported cluster state lock version {}", lock.version),
|
||||
)),
|
||||
Err(err) => diagnostics.push(Diagnostic::warning(
|
||||
"invalid_state_lock",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not parse state lock: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => diagnostics.push(Diagnostic::warning(
|
||||
"state_lock_read_error",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not read state lock: {err}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn observe_lock_metadata_lossy(
|
||||
&self,
|
||||
observations: &mut StateObservations,
|
||||
) {
|
||||
observations.locked = true;
|
||||
let lock_uri = self.uri(CLUSTER_LOCK_FILE);
|
||||
if let Ok(Some((text, _))) = self.read_versioned_opt(&lock_uri).await {
|
||||
if let Ok(lock) = serde_json::from_str::<StateLockFile>(&text) {
|
||||
if lock.version == 1 {
|
||||
observations.observe_lock_metadata(&lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_cas_mismatch() -> Diagnostic {
|
||||
Diagnostic::error(
|
||||
"state_cas_mismatch",
|
||||
CLUSTER_STATE_FILE,
|
||||
"state.json changed while the command was running; re-run the command against the latest state",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_lock_file_for_unlock(text: &str) -> Result<StateLockFile, Diagnostic> {
|
||||
let lock = serde_json::from_str::<StateLockFile>(text).map_err(|err| {
|
||||
Diagnostic::error(
|
||||
"invalid_state_lock",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("could not parse state lock: {err}"),
|
||||
)
|
||||
})?;
|
||||
if lock.version != 1 {
|
||||
return Err(Diagnostic::error(
|
||||
"unsupported_state_lock_version",
|
||||
CLUSTER_LOCK_FILE,
|
||||
format!("unsupported cluster state lock version {}", lock.version),
|
||||
));
|
||||
}
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
pub(crate) fn state_lock_held_message(observations: &StateObservations) -> String {
|
||||
match observations.lock_id.as_deref() {
|
||||
Some(lock_id) => format!(
|
||||
"cluster state lock already exists (lock id {lock_id}); run `omnigraph cluster force-unlock {lock_id}` only after confirming no cluster operation is active"
|
||||
),
|
||||
None => "cluster state lock already exists; remove it only after confirming no cluster operation is active".to_string(),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,441 +0,0 @@
|
|||
//! The recovery sweep: RFC-004's roll-forward-only sidecar
|
||||
//! classification (moved verbatim from lib.rs in the modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Recovery sweep (RFC-004 §D3): runs at the start of every state-mutating
|
||||
/// cluster command, under the state lock, before the command's own work.
|
||||
/// Roll-forward-only — the engine's own sidecars make each graph-level
|
||||
/// operation atomic within the graph, so the cluster never rolls a graph
|
||||
/// back; it converges the ledger to observable reality or refuses loudly.
|
||||
/// Mutations ride the calling command's CAS-checked state write; completed
|
||||
/// sidecars are deleted only after that write lands.
|
||||
pub(crate) async fn sweep_recovery_sidecars(
|
||||
backend: &ClusterStore,
|
||||
state: &mut ClusterState,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) -> SweepOutcome {
|
||||
let mut outcome = SweepOutcome::default();
|
||||
for (path, sidecar) in backend.list_recovery_sidecars(diagnostics).await {
|
||||
match sidecar.kind {
|
||||
RecoverySidecarKind::GraphCreate => {
|
||||
sweep_graph_create_sidecar(
|
||||
backend,
|
||||
path,
|
||||
sidecar,
|
||||
state,
|
||||
diagnostics,
|
||||
&mut outcome,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
RecoverySidecarKind::SchemaApply => {
|
||||
sweep_schema_apply_sidecar(path, sidecar, state, diagnostics, &mut outcome).await;
|
||||
}
|
||||
RecoverySidecarKind::GraphDelete => {
|
||||
sweep_graph_delete_sidecar(
|
||||
backend,
|
||||
path,
|
||||
sidecar,
|
||||
state,
|
||||
diagnostics,
|
||||
&mut outcome,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn sweep_graph_create_sidecar(
|
||||
backend: &ClusterStore,
|
||||
path: String,
|
||||
sidecar: RecoverySidecar,
|
||||
state: &mut ClusterState,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
outcome: &mut SweepOutcome,
|
||||
) {
|
||||
let graph_address = graph_address(&sidecar.graph_id);
|
||||
let schema_addr = schema_address(&sidecar.graph_id);
|
||||
|
||||
// Row 1: nothing moved — the init never landed. The sidecar is pure
|
||||
// intent; retire it (deferred to the command's post-CAS cleanup, like
|
||||
// every other completed sidecar — a failed CAS simply re-sweeps it) and
|
||||
// let the command's own plan re-propose the create.
|
||||
if !backend.graph_root_exists(&sidecar.graph_uri).await {
|
||||
outcome.completed_sidecars.push(path);
|
||||
return;
|
||||
}
|
||||
|
||||
match Omnigraph::open_read_only(&sidecar.graph_uri).await {
|
||||
Ok(db) => {
|
||||
let live_digest = sha256_hex(db.schema_source().as_bytes());
|
||||
let recorded = state
|
||||
.applied_revision
|
||||
.resources
|
||||
.get(&schema_addr)
|
||||
.map(|resource| resource.digest.clone());
|
||||
if recorded.as_deref() == Some(live_digest.as_str()) {
|
||||
// Row 2: crash fell between the state CAS and sidecar delete.
|
||||
outcome.completed_sidecars.push(path);
|
||||
} else if live_digest == sidecar.desired_schema_digest {
|
||||
// Row 4: the create completed on the graph; roll the cluster
|
||||
// state forward to observable reality.
|
||||
state.applied_revision.resources.insert(
|
||||
schema_addr.clone(),
|
||||
StateResource {
|
||||
digest: live_digest.clone(),
|
||||
applies_to: None,
|
||||
embedding_provider: None,
|
||||
embedding_profile: None,
|
||||
},
|
||||
);
|
||||
let query_digests = state_query_digests_for_graph(state, &sidecar.graph_id);
|
||||
let embedding_provider = state_graph_embedding_provider(state, &sidecar.graph_id);
|
||||
let embedding_provider_digest =
|
||||
state_embedding_provider_digest(state, embedding_provider.as_deref());
|
||||
let composite = graph_digest(
|
||||
&sidecar.graph_id,
|
||||
Some(&live_digest),
|
||||
Some(&query_digests),
|
||||
embedding_provider.as_deref(),
|
||||
embedding_provider_digest.as_ref(),
|
||||
);
|
||||
state.applied_revision.resources.insert(
|
||||
graph_address.clone(),
|
||||
StateResource {
|
||||
digest: composite,
|
||||
applies_to: None,
|
||||
embedding_provider,
|
||||
embedding_profile: None,
|
||||
},
|
||||
);
|
||||
set_resource_status_applied(state, &graph_address);
|
||||
set_resource_status_applied(state, &schema_addr);
|
||||
state.recovery_records.insert(
|
||||
sidecar.operation_id.clone(),
|
||||
json!({
|
||||
"kind": "graph_create",
|
||||
"graph_id": sidecar.graph_id,
|
||||
"outcome": "rolled_forward",
|
||||
"recovered_at": now_rfc3339(),
|
||||
"actor": sidecar.actor,
|
||||
}),
|
||||
);
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_rolled_forward",
|
||||
graph_address.clone(),
|
||||
"an interrupted graph create had completed on the graph; cluster state was rolled forward to match",
|
||||
));
|
||||
outcome.completed_sidecars.push(path);
|
||||
} else {
|
||||
// Row 6: the graph moved to something the sidecar did not
|
||||
// intend. Refuse to guess; require refresh + operator re-plan.
|
||||
set_resource_status(
|
||||
state,
|
||||
&graph_address,
|
||||
ResourceLifecycleStatus::Drifted,
|
||||
"actual_applied_state_pending",
|
||||
"graph state does not match the interrupted operation; run `cluster refresh` and re-plan",
|
||||
);
|
||||
set_resource_status(
|
||||
state,
|
||||
&schema_addr,
|
||||
ResourceLifecycleStatus::Drifted,
|
||||
"actual_applied_state_pending",
|
||||
"graph state does not match the interrupted operation; run `cluster refresh` and re-plan",
|
||||
);
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_pending",
|
||||
graph_address.clone(),
|
||||
"an interrupted graph create left unexpected graph state; graph-moving work is blocked until repaired",
|
||||
));
|
||||
outcome.pending_graphs.insert(sidecar.graph_id.clone());
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Row 5: partial root (the engine's documented init gap). Never
|
||||
// auto-delete — reconciler deletes are the same data-loss class
|
||||
// as human deletes; the operator removes the root explicitly.
|
||||
set_resource_status(
|
||||
state,
|
||||
&graph_address,
|
||||
ResourceLifecycleStatus::Error,
|
||||
"graph_create_incomplete",
|
||||
"graph root exists but cannot be opened; remove the graph root and re-run `cluster apply`",
|
||||
);
|
||||
set_resource_status(
|
||||
state,
|
||||
&schema_addr,
|
||||
ResourceLifecycleStatus::Error,
|
||||
"graph_create_incomplete",
|
||||
"graph root exists but cannot be opened; remove the graph root and re-run `cluster apply`",
|
||||
);
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"graph_create_incomplete",
|
||||
graph_address.clone(),
|
||||
format!(
|
||||
"graph root '{}' exists but cannot be opened ({err}); remove the graph root and re-run `cluster apply`",
|
||||
sidecar.graph_uri
|
||||
),
|
||||
));
|
||||
outcome.pending_graphs.insert(sidecar.graph_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn sweep_schema_apply_sidecar(
|
||||
path: String,
|
||||
sidecar: RecoverySidecar,
|
||||
state: &mut ClusterState,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
outcome: &mut SweepOutcome,
|
||||
) {
|
||||
let graph_address = graph_address(&sidecar.graph_id);
|
||||
let schema_addr = schema_address(&sidecar.graph_id);
|
||||
|
||||
// Digest-based classification: robust to unrelated manifest movement;
|
||||
// the sidecar's version pins stay forensic.
|
||||
let live_digest = match Omnigraph::open_read_only(&sidecar.graph_uri).await {
|
||||
Ok(db) => sha256_hex(db.schema_source().as_bytes()),
|
||||
Err(err) => {
|
||||
// Cannot verify the interrupted operation — refuse to guess.
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_pending",
|
||||
graph_address.clone(),
|
||||
format!(
|
||||
"an interrupted schema apply cannot be verified (graph '{}' did not open: {err}); graph-moving work is blocked until repaired",
|
||||
sidecar.graph_uri
|
||||
),
|
||||
));
|
||||
outcome.pending_graphs.insert(sidecar.graph_id.clone());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let recorded = state
|
||||
.applied_revision
|
||||
.resources
|
||||
.get(&schema_addr)
|
||||
.map(|resource| resource.digest.clone());
|
||||
if recorded.as_deref() == Some(live_digest.as_str()) {
|
||||
// Ledger consistent with the live graph (the apply never landed, or
|
||||
// landed and was recorded): the sidecar is stale intent — retire it.
|
||||
outcome.completed_sidecars.push(path);
|
||||
} else if live_digest == sidecar.desired_schema_digest {
|
||||
// RFC-004 §D3 row 3: the schema apply completed on the graph; roll
|
||||
// the cluster state forward to observable reality.
|
||||
state.applied_revision.resources.insert(
|
||||
schema_addr.clone(),
|
||||
StateResource {
|
||||
digest: live_digest.clone(),
|
||||
applies_to: None,
|
||||
embedding_provider: None,
|
||||
embedding_profile: None,
|
||||
},
|
||||
);
|
||||
let query_digests = state_query_digests_for_graph(state, &sidecar.graph_id);
|
||||
let embedding_provider = state_graph_embedding_provider(state, &sidecar.graph_id);
|
||||
let embedding_provider_digest =
|
||||
state_embedding_provider_digest(state, embedding_provider.as_deref());
|
||||
let composite = graph_digest(
|
||||
&sidecar.graph_id,
|
||||
Some(&live_digest),
|
||||
Some(&query_digests),
|
||||
embedding_provider.as_deref(),
|
||||
embedding_provider_digest.as_ref(),
|
||||
);
|
||||
state.applied_revision.resources.insert(
|
||||
graph_address.clone(),
|
||||
StateResource {
|
||||
digest: composite,
|
||||
applies_to: None,
|
||||
embedding_provider,
|
||||
embedding_profile: None,
|
||||
},
|
||||
);
|
||||
set_resource_status_applied(state, &graph_address);
|
||||
set_resource_status_applied(state, &schema_addr);
|
||||
state.recovery_records.insert(
|
||||
sidecar.operation_id.clone(),
|
||||
json!({
|
||||
"kind": "schema_apply",
|
||||
"graph_id": sidecar.graph_id,
|
||||
"outcome": "rolled_forward",
|
||||
"recovered_at": now_rfc3339(),
|
||||
"actor": sidecar.actor,
|
||||
}),
|
||||
);
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_rolled_forward",
|
||||
graph_address.clone(),
|
||||
"an interrupted schema apply had completed on the graph; cluster state was rolled forward to match",
|
||||
));
|
||||
outcome.completed_sidecars.push(path);
|
||||
} else {
|
||||
// Row 6: live schema is neither the recorded nor the desired digest.
|
||||
set_resource_status(
|
||||
state,
|
||||
&graph_address,
|
||||
ResourceLifecycleStatus::Drifted,
|
||||
"actual_applied_state_pending",
|
||||
"graph state does not match the interrupted operation; run `cluster refresh` and re-plan",
|
||||
);
|
||||
set_resource_status(
|
||||
state,
|
||||
&schema_addr,
|
||||
ResourceLifecycleStatus::Drifted,
|
||||
"actual_applied_state_pending",
|
||||
"graph state does not match the interrupted operation; run `cluster refresh` and re-plan",
|
||||
);
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_pending",
|
||||
graph_address.clone(),
|
||||
"an interrupted schema apply left unexpected graph state; graph-moving work is blocked until repaired",
|
||||
));
|
||||
outcome.pending_graphs.insert(sidecar.graph_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn sweep_graph_delete_sidecar(
|
||||
backend: &ClusterStore,
|
||||
path: String,
|
||||
sidecar: RecoverySidecar,
|
||||
state: &mut ClusterState,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
outcome: &mut SweepOutcome,
|
||||
) {
|
||||
let graph_address = graph_address(&sidecar.graph_id);
|
||||
|
||||
if backend.graph_root_exists(&sidecar.graph_uri).await {
|
||||
// Row 8: the delete never completed. Prefix removal is idempotent and
|
||||
// works on partial roots, so the repair is simply the re-proposed,
|
||||
// still-approved delete on a later run — retire the stale intent.
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"graph_delete_incomplete",
|
||||
graph_address,
|
||||
"a previous graph delete did not complete; it will be re-proposed by plan and can be retried under its approval",
|
||||
));
|
||||
outcome.completed_sidecars.push(path);
|
||||
return;
|
||||
}
|
||||
|
||||
if !state
|
||||
.applied_revision
|
||||
.resources
|
||||
.contains_key(&graph_address)
|
||||
{
|
||||
// Row 7: already tombstoned (or never recorded); crash fell between
|
||||
// the state CAS and sidecar delete.
|
||||
outcome.completed_sidecars.push(path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Row 7b: the root is gone, the ledger is stale — roll forward the
|
||||
// tombstone, consume the approval the sidecar carries, audit.
|
||||
tombstone_graph_subtree(
|
||||
state,
|
||||
&sidecar.graph_id,
|
||||
sidecar.approval_id.as_deref(),
|
||||
sidecar.actor.as_deref(),
|
||||
);
|
||||
state.recovery_records.insert(
|
||||
sidecar.operation_id.clone(),
|
||||
json!({
|
||||
"kind": "graph_delete",
|
||||
"graph_id": sidecar.graph_id,
|
||||
"outcome": "rolled_forward",
|
||||
"recovered_at": now_rfc3339(),
|
||||
"actor": sidecar.actor,
|
||||
}),
|
||||
);
|
||||
if let Some(approval_id) = &sidecar.approval_id {
|
||||
record_approval_consumed(state, approval_id, &sidecar.operation_id);
|
||||
outcome.consumed_approvals.push(approval_id.clone());
|
||||
}
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_rolled_forward",
|
||||
graph_address,
|
||||
"an interrupted graph delete had completed on disk; cluster state was rolled forward to match",
|
||||
));
|
||||
outcome.completed_sidecars.push(path);
|
||||
}
|
||||
|
||||
/// Remove a graph's subtree (graph, schema, queries) from the ledger and
|
||||
/// leave a tombstone observation. Idempotent.
|
||||
pub(crate) fn tombstone_graph_subtree(
|
||||
state: &mut ClusterState,
|
||||
graph_id: &str,
|
||||
approval_id: Option<&str>,
|
||||
actor: Option<&str>,
|
||||
) {
|
||||
let graph_addr = graph_address(graph_id);
|
||||
let schema_addr = schema_address(graph_id);
|
||||
let query_prefix = format!("query.{graph_id}.");
|
||||
state.applied_revision.resources.remove(&graph_addr);
|
||||
state.applied_revision.resources.remove(&schema_addr);
|
||||
state
|
||||
.applied_revision
|
||||
.resources
|
||||
.retain(|address, _| !address.starts_with(&query_prefix));
|
||||
state.resource_statuses.remove(&graph_addr);
|
||||
state.resource_statuses.remove(&schema_addr);
|
||||
state
|
||||
.resource_statuses
|
||||
.retain(|address, _| !address.starts_with(&query_prefix));
|
||||
state.observations.insert(
|
||||
graph_addr,
|
||||
json!({
|
||||
"kind": "tombstone",
|
||||
"deleted_at": now_rfc3339(),
|
||||
"approval_id": approval_id,
|
||||
"actor": actor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Record approval consumption in the state ledger. The artifact FILE is
|
||||
/// rewritten with consumed_at only after the state write lands, so a failed
|
||||
/// CAS leaves the approval valid for the retry.
|
||||
pub(crate) fn record_approval_consumed(
|
||||
state: &mut ClusterState,
|
||||
approval_id: &str,
|
||||
operation_id: &str,
|
||||
) {
|
||||
state.approval_records.insert(
|
||||
approval_id.to_string(),
|
||||
json!({
|
||||
"consumed_at": now_rfc3339(),
|
||||
"consumed_by_operation": operation_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark approval artifact files consumed on disk (post-CAS).
|
||||
pub(crate) async fn mark_approvals_consumed(backend: &ClusterStore, approval_ids: &[String]) {
|
||||
if approval_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut sink = Vec::new();
|
||||
for (_, mut artifact) in backend.list_approval_artifacts(&mut sink).await {
|
||||
if approval_ids.contains(&artifact.approval_id) && artifact.consumed_at.is_none() {
|
||||
artifact.consumed_at = Some(now_rfc3339());
|
||||
let _ = backend.write_approval_artifact(&artifact).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only commands report pending sidecars without acting on them.
|
||||
pub(crate) async fn warn_pending_recovery_sidecars(
|
||||
backend: &ClusterStore,
|
||||
diagnostics: &mut Vec<Diagnostic>,
|
||||
) {
|
||||
for location in backend.list_recovery_sidecar_locations(diagnostics).await {
|
||||
diagnostics.push(Diagnostic::warning(
|
||||
"cluster_recovery_pending",
|
||||
location,
|
||||
"a recovery sidecar from an interrupted apply is pending; the next state-mutating command will classify it",
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,727 +0,0 @@
|
|||
//! Public output/diagnostic types and internal state/sidecar/approval
|
||||
//! models (moved verbatim from lib.rs in the modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSeverity {
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct Diagnostic {
|
||||
pub code: String,
|
||||
pub severity: DiagnosticSeverity,
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
pub(crate) fn error(code: impl Into<String>, path: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
severity: DiagnosticSeverity::Error,
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn warning(
|
||||
code: impl Into<String>,
|
||||
path: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
code: code.into(),
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
path: path.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ResourceSummary {
|
||||
pub address: String,
|
||||
pub kind: String,
|
||||
pub digest: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Dependency {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ValidateOutput {
|
||||
pub ok: bool,
|
||||
pub config_dir: String,
|
||||
pub config_file: String,
|
||||
pub resource_digests: BTreeMap<String, String>,
|
||||
pub resources: Vec<ResourceSummary>,
|
||||
pub dependencies: Vec<Dependency>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DesiredRevision {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub config_digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StateObservations {
|
||||
pub state_path: String,
|
||||
pub lock_path: String,
|
||||
pub state_found: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub applied_config_digest: Option<String>,
|
||||
pub state_revision: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_cas: Option<String>,
|
||||
pub resource_count: usize,
|
||||
pub locked: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_id: Option<String>,
|
||||
pub lock_acquired: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub acquired_lock_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_operation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_created_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_pid: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_age_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl StateObservations {
|
||||
pub(crate) fn observe_lock_metadata(&mut self, lock: &StateLockFile) {
|
||||
self.locked = true;
|
||||
self.lock_id = Some(lock.lock_id.clone());
|
||||
self.lock_operation = Some(lock.operation.clone());
|
||||
self.lock_created_at = Some(lock.created_at.clone());
|
||||
self.lock_pid = Some(lock.pid);
|
||||
self.lock_age_seconds = lock_age_seconds(&lock.created_at);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ResourceLifecycleStatus {
|
||||
Pending,
|
||||
Planned,
|
||||
Applying,
|
||||
Applied,
|
||||
Drifted,
|
||||
Blocked,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResourceStatusRecord {
|
||||
pub status: ResourceLifecycleStatus,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub conditions: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlanOperation {
|
||||
Create,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// How `cluster apply` treats a planned change in the current stage.
|
||||
///
|
||||
/// `Applied` changes execute (config-only query/policy catalog writes).
|
||||
/// `Derived` marks a `graph.<id>` composite-digest update that converges
|
||||
/// automatically once its applied query digests land in state. `Deferred`
|
||||
/// changes need a later phase (graph/schema lifecycle or schema content).
|
||||
/// `Blocked` query/policy changes are gated by an unapplied or missing
|
||||
/// dependency.
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ApplyDisposition {
|
||||
Applied,
|
||||
Derived,
|
||||
Deferred,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
pub struct PlanChange {
|
||||
pub resource: String,
|
||||
pub operation: PlanOperation,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub before_digest: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub after_digest: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disposition: Option<ApplyDisposition>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
/// True for a policy change whose file digest is unchanged but whose
|
||||
/// `applies_to` bindings differ from the applied revision (including the
|
||||
/// pre-5A backfill case).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub binding_change: bool,
|
||||
/// Metadata-only updates whose resource content digest is unchanged but
|
||||
/// whose applied ledger metadata needs to converge.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub metadata_change: Option<PlanMetadataChange>,
|
||||
/// For schema updates: the engine's migration plan against the live
|
||||
/// graph (RFC-004 §D7's data-aware preview). Absent when the preview is
|
||||
/// unavailable (warning `schema_preview_unavailable`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub migration: Option<SchemaMigrationPlan>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlanMetadataChange {
|
||||
PolicyBindings,
|
||||
EmbeddingProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct BlastRadius {
|
||||
pub resource: String,
|
||||
pub affected: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct ApprovalRequirement {
|
||||
pub resource: String,
|
||||
pub reason: String,
|
||||
/// True when a valid (digest-matching, unconsumed) approval artifact is
|
||||
/// pending for this change.
|
||||
pub satisfied: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlanOutput {
|
||||
pub ok: bool,
|
||||
pub config_dir: String,
|
||||
pub desired_revision: DesiredRevision,
|
||||
pub resource_digests: BTreeMap<String, String>,
|
||||
pub dependencies: Vec<Dependency>,
|
||||
pub state_observations: StateObservations,
|
||||
pub changes: Vec<PlanChange>,
|
||||
pub blast_radius: Vec<BlastRadius>,
|
||||
pub approvals_required: Vec<ApprovalRequirement>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StatusOutput {
|
||||
pub ok: bool,
|
||||
pub config_dir: String,
|
||||
pub state_observations: StateObservations,
|
||||
pub resource_digests: BTreeMap<String, String>,
|
||||
pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
|
||||
pub observations: BTreeMap<String, serde_json::Value>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StateSyncOperation {
|
||||
Refresh,
|
||||
Import,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct StateSyncOutput {
|
||||
pub ok: bool,
|
||||
pub operation: StateSyncOperation,
|
||||
pub config_dir: String,
|
||||
pub state_observations: StateObservations,
|
||||
pub resource_digests: BTreeMap<String, String>,
|
||||
pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
|
||||
pub observations: BTreeMap<String, serde_json::Value>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ForceUnlockOutput {
|
||||
pub ok: bool,
|
||||
pub config_dir: String,
|
||||
pub state_observations: StateObservations,
|
||||
pub lock_removed: bool,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Output of config-only `cluster apply`. "Applied" means recorded in the
|
||||
/// local cluster catalog (`__cluster/`); nothing applied here serves traffic —
|
||||
/// the server still boots from `omnigraph.yaml` until the server-boot stage.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ApplyOutput {
|
||||
pub ok: bool,
|
||||
pub config_dir: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub actor: Option<String>,
|
||||
pub desired_revision: DesiredRevision,
|
||||
pub state_observations: StateObservations,
|
||||
/// Every planned change, with `disposition`/`reason` always populated.
|
||||
pub changes: Vec<PlanChange>,
|
||||
pub applied_count: usize,
|
||||
/// Deferred + Blocked changes (Derived composite updates count as neither).
|
||||
pub deferred_count: usize,
|
||||
/// True when state matches the desired revision after this apply.
|
||||
pub converged: bool,
|
||||
/// False for a no-op re-apply: state bytes (and revision) were left untouched.
|
||||
pub state_written: bool,
|
||||
/// The statuses as persisted: post-apply on success, the pre-apply on-disk
|
||||
/// snapshot when the state write fails (never unpersisted in-memory state).
|
||||
pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// A digest-bound human approval for an irreversible operation (RFC-004
|
||||
/// §D4). Written by `cluster approve`, consumed by apply. The file is never
|
||||
/// deleted on consumption — it is rewritten with `consumed_at` and also
|
||||
/// summarized into the state ledger's `approval_records`, so the audit fact
|
||||
/// survives the loss of either store (axiom 11).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct ApprovalArtifact {
|
||||
pub(crate) schema_version: u32,
|
||||
pub(crate) approval_id: String,
|
||||
pub(crate) resource: String,
|
||||
pub(crate) operation: String,
|
||||
pub(crate) reason: String,
|
||||
pub(crate) bound_config_digest: String,
|
||||
#[serde(default)]
|
||||
pub(crate) bound_before_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) bound_after_digest: Option<String>,
|
||||
pub(crate) approved_by: String,
|
||||
pub(crate) created_at: String,
|
||||
#[serde(default)]
|
||||
pub(crate) consumed_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) consumed_by_operation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ApproveOutput {
|
||||
pub ok: bool,
|
||||
pub config_dir: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approval_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resource: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operation: Option<PlanOperation>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub approved_by: Option<String>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DesiredCluster {
|
||||
pub(crate) config_dir: PathBuf,
|
||||
pub(crate) config_digest: String,
|
||||
/// The declared `storage:` root, if any (None ⇒ the config dir itself).
|
||||
pub(crate) storage_root: Option<String>,
|
||||
pub(crate) state_lock: bool,
|
||||
pub(crate) embedding_providers: BTreeMap<String, EmbeddingProviderConfig>,
|
||||
pub(crate) graphs: Vec<DesiredGraph>,
|
||||
pub(crate) resource_digests: BTreeMap<String, String>,
|
||||
pub(crate) resources: Vec<ResourceSummary>,
|
||||
pub(crate) dependencies: Vec<Dependency>,
|
||||
/// `policy.<name>` address -> normalized applies_to refs.
|
||||
pub(crate) policy_bindings: BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DesiredGraph {
|
||||
pub(crate) id: String,
|
||||
pub(crate) schema_digest: String,
|
||||
pub(crate) embedding_provider: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ParsedConfig {
|
||||
pub(crate) raw: Option<RawClusterConfig>,
|
||||
pub(crate) diagnostics: Vec<Diagnostic>,
|
||||
pub(crate) config_dir: PathBuf,
|
||||
pub(crate) config_file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ClusterSettings {
|
||||
pub(crate) state_lock: bool,
|
||||
pub(crate) storage_root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct LoadOutcome {
|
||||
pub(crate) desired: Option<DesiredCluster>,
|
||||
pub(crate) diagnostics: Vec<Diagnostic>,
|
||||
pub(crate) config_dir: PathBuf,
|
||||
pub(crate) config_file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct RawClusterConfig {
|
||||
pub(crate) version: u32,
|
||||
#[serde(default)]
|
||||
pub(crate) metadata: Metadata,
|
||||
/// Storage root URI for everything the cluster stores: the state
|
||||
/// ledger, catalog, sidecars, approvals, and derived graph roots.
|
||||
/// Absent ⇒ `file://<config-dir>` (the original layout, byte-compatible).
|
||||
/// `s3://bucket/prefix` puts the whole cluster on object storage.
|
||||
#[serde(default)]
|
||||
pub(crate) storage: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) state: StateConfig,
|
||||
#[serde(default)]
|
||||
pub(crate) providers: ProvidersConfig,
|
||||
#[serde(default)]
|
||||
pub(crate) graphs: BTreeMap<String, GraphConfig>,
|
||||
#[serde(default)]
|
||||
pub(crate) policies: BTreeMap<String, PolicyConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Metadata {
|
||||
pub(crate) name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct StateConfig {
|
||||
pub(crate) backend: Option<String>,
|
||||
pub(crate) lock: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct ProvidersConfig {
|
||||
#[serde(default)]
|
||||
pub(crate) embedding: BTreeMap<String, EmbeddingProviderConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct GraphConfig {
|
||||
pub(crate) schema: PathBuf,
|
||||
#[serde(default)]
|
||||
pub(crate) queries: QueriesDecl,
|
||||
/// Optional reference to a top-level `providers.embedding.<name>` profile.
|
||||
#[serde(default)]
|
||||
pub(crate) embedding_provider: Option<String>,
|
||||
}
|
||||
|
||||
/// A named cluster embedding provider profile (RFC-012 Phase 5). `kind`/`base_url`/
|
||||
/// `model` default exactly as the engine's `EmbeddingConfig::from_env` does.
|
||||
/// `api_key`, when required, must be a `${NAME}` env reference resolved at
|
||||
/// serving boot, never an inline secret.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EmbeddingProviderConfig {
|
||||
#[serde(default, alias = "provider", skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl EmbeddingProviderConfig {
|
||||
pub(crate) fn validate(&self, path: String, diagnostics: &mut Vec<Diagnostic>) {
|
||||
if let Err(error) = omnigraph::embedding::EmbeddingConfig::from_parts(
|
||||
self.kind.as_deref(),
|
||||
self.base_url.clone(),
|
||||
self.model.clone(),
|
||||
"validation-placeholder".to_string(),
|
||||
) {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"invalid_embedding_provider",
|
||||
path.clone(),
|
||||
error.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.kind.as_deref() == Some("mock") {
|
||||
if let Some(api_key) = self.api_key.as_deref() {
|
||||
if secret_ref_name(api_key).is_err() {
|
||||
diagnostics.push(Diagnostic::error(
|
||||
"embedding_api_key_inline",
|
||||
format!("{path}.api_key"),
|
||||
"embedding api_key must be a ${NAME} env reference, not an inline secret",
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match self.api_key.as_deref() {
|
||||
Some(api_key) if secret_ref_name(api_key).is_err() => diagnostics.push(
|
||||
Diagnostic::error(
|
||||
"embedding_api_key_inline",
|
||||
format!("{path}.api_key"),
|
||||
"embedding api_key must be a ${NAME} env reference, not an inline secret",
|
||||
),
|
||||
),
|
||||
Some(_) => {}
|
||||
None => diagnostics.push(Diagnostic::error(
|
||||
"embedding_api_key_required",
|
||||
format!("{path}.api_key"),
|
||||
"non-mock embedding providers must set api_key to a ${NAME} env reference",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve into an engine `EmbeddingConfig`, reading the `${NAME}` api-key
|
||||
/// reference from process env. Mock profiles do not read env and may omit
|
||||
/// `api_key`; real providers error if the reference is missing or unset.
|
||||
pub fn resolve(&self) -> Result<omnigraph::embedding::EmbeddingConfig, String> {
|
||||
let api_key = if self.kind.as_deref() == Some("mock") {
|
||||
String::new()
|
||||
} else {
|
||||
resolve_secret_ref(self.api_key.as_deref().ok_or_else(|| {
|
||||
"embedding api_key is required for non-mock providers".to_string()
|
||||
})?)?
|
||||
};
|
||||
omnigraph::embedding::EmbeddingConfig::from_parts(
|
||||
self.kind.as_deref(),
|
||||
self.base_url.clone(),
|
||||
self.model.clone(),
|
||||
api_key,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn secret_ref_name(value: &str) -> Result<&str, String> {
|
||||
value
|
||||
.trim()
|
||||
.strip_prefix("${")
|
||||
.and_then(|s| s.strip_suffix('}'))
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
format!("embedding api_key must be a ${{NAME}} env reference, got '{}'", value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a `${NAME}` secret reference from process env. Rejects an inline value
|
||||
/// (anything not wrapped in `${…}`) so secrets never sit in the cluster config.
|
||||
fn resolve_secret_ref(value: &str) -> Result<String, String> {
|
||||
let name = secret_ref_name(value)?;
|
||||
std::env::var(name).map_err(|_| format!("embedding api_key env var '{name}' is not set"))
|
||||
}
|
||||
|
||||
/// How a graph declares its stored queries. Terraform-style: the `.gq`
|
||||
/// files ARE the declaration — point at them (or a directory) and every
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct QueryConfig {
|
||||
pub(crate) file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct PolicyConfig {
|
||||
pub(crate) file: PathBuf,
|
||||
pub(crate) applies_to: Vec<String>,
|
||||
}
|
||||
|
||||
// Stage 2A/2B accept these forward-compatible state sections so existing
|
||||
// ledgers won't churn while approval/recovery semantics are staged later.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct ClusterState {
|
||||
pub(crate) version: u32,
|
||||
#[serde(default)]
|
||||
pub(crate) state_revision: u64,
|
||||
pub(crate) applied_revision: AppliedRevisionState,
|
||||
#[serde(default)]
|
||||
pub(crate) resource_statuses: BTreeMap<String, ResourceStatusRecord>,
|
||||
#[serde(default)]
|
||||
pub(crate) approval_records: BTreeMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub(crate) recovery_records: BTreeMap<String, serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub(crate) observations: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct AppliedRevisionState {
|
||||
#[serde(default)]
|
||||
pub(crate) config_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) resources: BTreeMap<String, StateResource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct StateResource {
|
||||
pub(crate) digest: String,
|
||||
/// Policy resources only: the applied `applies_to` bindings, normalized
|
||||
/// to typed refs (`cluster` | `graph.<id>`). Recorded so the state
|
||||
/// ledger is serving-sufficient for the Phase-5 server boot (RFC-005
|
||||
/// §D3). Absent on pre-5A entries (backfilled by the next apply) and on
|
||||
/// non-policy resources.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) applies_to: Option<Vec<String>>,
|
||||
/// Graph resources only: the applied `provider.embedding.<name>` binding.
|
||||
/// The provider profile itself is stored on the provider resource so
|
||||
/// serving can boot without re-reading mutable desired config.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) embedding_provider: Option<String>,
|
||||
/// Embedding provider resources only: the applied profile with unresolved
|
||||
/// `${ENV}` references. The server resolves the referenced env var exactly
|
||||
/// once at boot and injects the resulting engine config into the graph.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) embedding_profile: Option<EmbeddingProviderConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct StateLockFile {
|
||||
pub(crate) version: u32,
|
||||
pub(crate) lock_id: String,
|
||||
pub(crate) operation: String,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) pid: u32,
|
||||
}
|
||||
|
||||
/// Recovery-intent record for a graph-moving apply operation (RFC-004 §D2).
|
||||
/// Written under the state lock before the engine call that can create or
|
||||
/// move a graph manifest; deleted only after the cluster state CAS that
|
||||
/// records the outcome lands. The sweep (§D3) classifies survivors.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct RecoverySidecar {
|
||||
pub(crate) schema_version: u32,
|
||||
pub(crate) operation_id: String,
|
||||
pub(crate) started_at: String,
|
||||
#[serde(default)]
|
||||
pub(crate) actor: Option<String>,
|
||||
pub(crate) kind: RecoverySidecarKind,
|
||||
pub(crate) graph_id: String,
|
||||
pub(crate) graph_uri: String,
|
||||
#[serde(default)]
|
||||
pub(crate) observed_manifest_version: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub(crate) expected_manifest_version: Option<u64>,
|
||||
pub(crate) desired_schema_digest: String,
|
||||
#[serde(default)]
|
||||
pub(crate) state_cas_base: Option<String>,
|
||||
/// For graph_delete: the approval this operation consumes; lets a sweep
|
||||
/// roll-forward consume it too.
|
||||
#[serde(default)]
|
||||
pub(crate) approval_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum RecoverySidecarKind {
|
||||
GraphCreate,
|
||||
SchemaApply,
|
||||
GraphDelete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SweepOutcome {
|
||||
/// Graphs whose sidecar was kept (rows 5/6): graph-moving work for them
|
||||
/// is blocked until the operator repairs and re-observes.
|
||||
pub(crate) pending_graphs: BTreeSet<String>,
|
||||
/// Sidecars whose outcome is recorded (rows 2/4): deleted only after the
|
||||
/// command's state write lands, so a CAS failure re-sweeps them.
|
||||
/// Store URIs (the storage layer addresses everything by URI).
|
||||
pub(crate) completed_sidecars: Vec<String>,
|
||||
/// Approval artifacts consumed by a roll-forward (delete row 7b): their
|
||||
/// files are rewritten with consumed_at only after the state write lands.
|
||||
pub(crate) consumed_approvals: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod embedding_provider_config_tests {
|
||||
use super::EmbeddingProviderConfig;
|
||||
|
||||
#[test]
|
||||
fn resolves_secret_from_env_and_applies_defaults() {
|
||||
// SAFETY: a unique var name, no concurrent reader.
|
||||
unsafe { std::env::set_var("OG_TEST_EMBED_KEY_A", "secret-x") };
|
||||
let profile = EmbeddingProviderConfig {
|
||||
kind: Some("openai-compatible".to_string()),
|
||||
base_url: None,
|
||||
model: Some("m".to_string()),
|
||||
api_key: Some("${OG_TEST_EMBED_KEY_A}".to_string()),
|
||||
};
|
||||
let config = profile.resolve().unwrap();
|
||||
assert_eq!(config.api_key, "secret-x");
|
||||
assert_eq!(config.model, "m");
|
||||
unsafe { std::env::remove_var("OG_TEST_EMBED_KEY_A") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_inline_api_key() {
|
||||
let profile = EmbeddingProviderConfig {
|
||||
kind: None,
|
||||
base_url: None,
|
||||
model: None,
|
||||
api_key: Some("sk-inline".to_string()),
|
||||
};
|
||||
let err = profile.resolve().unwrap_err();
|
||||
assert!(err.contains("${NAME}"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_on_unset_secret() {
|
||||
let profile = EmbeddingProviderConfig {
|
||||
kind: None,
|
||||
base_url: None,
|
||||
model: None,
|
||||
api_key: Some("${OG_TEST_DEFINITELY_UNSET_VAR}".to_string()),
|
||||
};
|
||||
let err = profile.resolve().unwrap_err();
|
||||
assert!(err.contains("not set"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_provider() {
|
||||
unsafe { std::env::set_var("OG_TEST_EMBED_KEY_B", "x") };
|
||||
let profile = EmbeddingProviderConfig {
|
||||
kind: Some("cohere".to_string()),
|
||||
base_url: None,
|
||||
model: None,
|
||||
api_key: Some("${OG_TEST_EMBED_KEY_B}".to_string()),
|
||||
};
|
||||
let err = profile.resolve().unwrap_err();
|
||||
assert!(err.contains("unknown embedding provider"), "got: {err}");
|
||||
unsafe { std::env::remove_var("OG_TEST_EMBED_KEY_B") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_does_not_require_secret_env() {
|
||||
let profile = EmbeddingProviderConfig {
|
||||
kind: Some("mock".to_string()),
|
||||
base_url: None,
|
||||
model: Some("cluster-mock".to_string()),
|
||||
api_key: None,
|
||||
};
|
||||
let config = profile.resolve().unwrap();
|
||||
assert_eq!(config.model, "cluster-mock");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,707 +0,0 @@
|
|||
//! Fault-injection tests for the cluster apply protocol.
|
||||
//!
|
||||
//! These live in an integration binary (not in-source) deliberately: the fail
|
||||
//! crate's registry is process-global, so a configured `cluster_apply.*`
|
||||
//! action would fire inside any concurrently running normal apply test in the
|
||||
//! lib-test process. A separate binary isolates the registry by construction —
|
||||
//! same reason the engine keeps its failpoint suite in `tests/failpoints.rs`.
|
||||
|
||||
#![cfg(feature = "failpoints")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fail::FailScenario;
|
||||
use serial_test::serial;
|
||||
use omnigraph::db::Omnigraph;
|
||||
// One ScopedFailPoint for both engine- and cluster-scoped failpoint names:
|
||||
// it is registry-only (error-type agnostic) and lives in the lowest crate.
|
||||
use omnigraph::failpoints::ScopedFailPoint;
|
||||
use omnigraph_cluster::{
|
||||
ApplyOptions, apply_config_dir, apply_config_dir_with_options, approve_config_dir,
|
||||
validate_config_dir,
|
||||
};
|
||||
use tempfile::tempdir;
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
}
|
||||
"#;
|
||||
|
||||
const QUERY: &str = r#"
|
||||
query find_person($name: String) {
|
||||
match { $p: Person { name: $name } }
|
||||
return { $p.name, $p.age }
|
||||
}
|
||||
"#;
|
||||
|
||||
fn fixture() -> tempfile::TempDir {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("people.pg"), SCHEMA).unwrap();
|
||||
fs::write(dir.path().join("people.gq"), QUERY).unwrap();
|
||||
fs::write(dir.path().join("base.policy.yaml"), "rules: []\n").unwrap();
|
||||
fs::write(
|
||||
dir.path().join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
policies:
|
||||
base:
|
||||
file: ./base.policy.yaml
|
||||
applies_to: [knowledge]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Seed a state.json where the graph/schema digests match desired, so query
|
||||
/// and policy changes are applicable. Digests are borrowed from the public
|
||||
/// validate output; the graph composite is a placeholder that apply converges
|
||||
/// as a Derived update.
|
||||
fn seed_applyable_state(config_dir: &Path) -> BTreeMap<String, String> {
|
||||
let validate = validate_config_dir(config_dir);
|
||||
assert!(validate.ok, "{:?}", validate.diagnostics);
|
||||
let schema_digest = validate.resource_digests["schema.knowledge"].clone();
|
||||
let state_dir = config_dir.join("__cluster");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::write(
|
||||
state_dir.join("state.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"version": 1,
|
||||
"state_revision": 1,
|
||||
"applied_revision": {{
|
||||
"resources": {{
|
||||
"graph.knowledge": {{ "digest": "seed" }},
|
||||
"schema.knowledge": {{ "digest": "{schema_digest}" }}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
validate.resource_digests
|
||||
}
|
||||
|
||||
fn state_path(config_dir: &Path) -> PathBuf {
|
||||
config_dir.join("__cluster/state.json")
|
||||
}
|
||||
|
||||
fn query_blob(config_dir: &Path, digests: &BTreeMap<String, String>) -> PathBuf {
|
||||
config_dir
|
||||
.join("__cluster/resources/query/knowledge/find_person")
|
||||
.join(format!("{}.gq", digests["query.knowledge.find_person"]))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn failpoint_wiring_returns_injected_diagnostic() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
seed_applyable_state(dir.path());
|
||||
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_AFTER_PAYLOAD_PHASE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(out.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.code == "injected_failpoint"
|
||||
&& diagnostic
|
||||
.message
|
||||
.contains("cluster_apply.after_payload_phase")
|
||||
}));
|
||||
drop(_failpoint);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Crash between the payload phase and the state write: blobs are on disk,
|
||||
/// state.json is byte-identical, nothing is acknowledged — and a plain re-run
|
||||
/// repairs by trusting the existing content-addressed blobs.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn apply_crash_after_payload_phase_leaves_state_unmoved_then_recovers() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
let digests = seed_applyable_state(dir.path());
|
||||
let state_before = fs::read(state_path(dir.path())).unwrap();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_AFTER_PAYLOAD_PHASE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(!out.state_written);
|
||||
assert!(!out.converged);
|
||||
assert_eq!(out.applied_count, 0);
|
||||
// Persisted pre-apply snapshot: no phantom Applied statuses.
|
||||
assert!(
|
||||
!out.resource_statuses
|
||||
.contains_key("query.knowledge.find_person"),
|
||||
"{:?}",
|
||||
out.resource_statuses
|
||||
);
|
||||
// State has not moved; payloads are inert on disk; the lock released.
|
||||
assert_eq!(fs::read(state_path(dir.path())).unwrap(), state_before);
|
||||
assert!(query_blob(dir.path(), &digests).exists());
|
||||
assert!(!dir.path().join("__cluster/lock.json").exists());
|
||||
}
|
||||
|
||||
// The repair is a plain re-run: existing blobs are trusted by digest.
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(recovered.converged);
|
||||
assert!(recovered.state_written);
|
||||
assert_eq!(
|
||||
recovered.resource_statuses["query.knowledge.find_person"].status,
|
||||
omnigraph_cluster::ResourceLifecycleStatus::Applied
|
||||
);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// A concurrent writer mutating state.json between apply's read and its write
|
||||
/// (possible under `state.lock: false`) must surface `state_cas_mismatch`,
|
||||
/// acknowledge nothing, and leave the concurrent writer's state on disk.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn apply_cas_race_surfaces_state_cas_mismatch() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
let digests = seed_applyable_state(dir.path());
|
||||
|
||||
// Simulate the concurrent writer at the exact race window: rewrite
|
||||
// state.json (valid JSON, graph/schema digests preserved, revision 99)
|
||||
// after apply read it but before apply writes. RAII-guarded so a panic
|
||||
// inside apply cannot leak the callback into the global registry.
|
||||
let race_path = state_path(dir.path());
|
||||
let failpoint = ScopedFailPoint::with_callback(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_BEFORE_STATE_WRITE, move || {
|
||||
let mut state: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&race_path).unwrap()).unwrap();
|
||||
state["state_revision"] = serde_json::json!(99);
|
||||
fs::write(&race_path, serde_json::to_string_pretty(&state).unwrap()).unwrap();
|
||||
});
|
||||
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
drop(failpoint);
|
||||
|
||||
assert!(!out.ok);
|
||||
assert!(!out.state_written);
|
||||
assert!(
|
||||
out.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "state_cas_mismatch"),
|
||||
"{:?}",
|
||||
out.diagnostics
|
||||
);
|
||||
// Persisted snapshot, not the unwritten in-memory mutations.
|
||||
assert!(
|
||||
!out.resource_statuses
|
||||
.contains_key("query.knowledge.find_person")
|
||||
);
|
||||
// The concurrent writer's state is what's on disk; apply's mutation never landed.
|
||||
let state: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(state_path(dir.path())).unwrap()).unwrap();
|
||||
assert_eq!(state["state_revision"], 99);
|
||||
assert!(
|
||||
state["applied_revision"]["resources"]
|
||||
.get("query.knowledge.find_person")
|
||||
.is_none()
|
||||
);
|
||||
// Blobs written before the race are inert.
|
||||
assert!(query_blob(dir.path(), &digests).exists());
|
||||
|
||||
// Recovery is a plain re-run against the rewritten state.
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(recovered.converged);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
fn seed_empty_state(config_dir: &Path) {
|
||||
let state_dir = config_dir.join("__cluster");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::write(
|
||||
state_dir.join("state.json"),
|
||||
r#"{
|
||||
"version": 1,
|
||||
"state_revision": 1,
|
||||
"applied_revision": { "resources": {} }
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn recovery_sidecars(config_dir: &Path) -> Vec<PathBuf> {
|
||||
match fs::read_dir(config_dir.join("__cluster/recoveries")) {
|
||||
Ok(entries) => {
|
||||
let mut paths: Vec<PathBuf> = entries
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
|
||||
.collect();
|
||||
paths.sort();
|
||||
paths
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Crash before the init: the create-intent sidecar survives, nothing moved.
|
||||
/// The next run's sweep removes the intent (row 1) and the same run creates
|
||||
/// the graph and converges.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn create_crash_before_init_recovers_via_sweep() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
seed_empty_state(dir.path());
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_BEFORE_GRAPH_CREATE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(out.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.code == "injected_failpoint"
|
||||
&& diagnostic
|
||||
.message
|
||||
.contains("cluster_apply.before_graph_create")
|
||||
}));
|
||||
assert_eq!(recovery_sidecars(dir.path()).len(), 1);
|
||||
assert!(!dir.path().join("graphs/knowledge.omni").exists());
|
||||
// No resource digest moved.
|
||||
let state: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(dir.path().join("__cluster/state.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
state["applied_revision"]["resources"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(recovered.converged);
|
||||
assert!(dir.path().join("graphs/knowledge.omni").exists());
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Crash after the init but before the state CAS: the graph exists, the
|
||||
/// ledger is stale, nothing was acknowledged. The next run's sweep rolls the
|
||||
/// ledger forward (row 4) with an audit entry, and the run converges.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn create_crash_after_init_rolls_state_forward() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
seed_empty_state(dir.path());
|
||||
let state_before = fs::read(dir.path().join("__cluster/state.json")).unwrap();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_AFTER_GRAPH_CREATE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(!out.state_written);
|
||||
// The graph exists; the cluster state is byte-identical (no ack).
|
||||
assert!(dir.path().join("graphs/knowledge.omni").exists());
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("__cluster/state.json")).unwrap(),
|
||||
state_before
|
||||
);
|
||||
// The sidecar carries the post-init manifest pin.
|
||||
let sidecars = recovery_sidecars(dir.path());
|
||||
assert_eq!(sidecars.len(), 1);
|
||||
let sidecar: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&sidecars[0]).unwrap()).unwrap();
|
||||
assert!(
|
||||
sidecar["expected_manifest_version"].is_number(),
|
||||
"{sidecar}"
|
||||
);
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(
|
||||
recovered
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "cluster_recovery_rolled_forward")
|
||||
);
|
||||
assert!(recovered.converged);
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
let state: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.path().join("__cluster/state.json")).unwrap())
|
||||
.unwrap();
|
||||
assert!(
|
||||
state["recovery_records"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|record| record["outcome"] == "rolled_forward")
|
||||
);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
const SCHEMA_V2: &str = r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
bio: String?
|
||||
}
|
||||
"#;
|
||||
|
||||
async fn converge_with_live_graph(dir: &Path) {
|
||||
let graph_dir = dir.join("graphs");
|
||||
fs::create_dir_all(&graph_dir).unwrap();
|
||||
Omnigraph::init(
|
||||
graph_dir.join("knowledge.omni").to_string_lossy().as_ref(),
|
||||
SCHEMA,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
seed_applyable_state(dir);
|
||||
let out = apply_config_dir(dir).await;
|
||||
assert!(out.ok && out.converged, "{:?}", out.diagnostics);
|
||||
}
|
||||
|
||||
async fn live_schema_digest(dir: &Path) -> String {
|
||||
let uri = dir.join("graphs/knowledge.omni");
|
||||
let db = Omnigraph::open_read_only(uri.to_string_lossy().as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(db.schema_source().as_bytes());
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Crash before the engine schema apply: sidecar (with actor) survives, the
|
||||
/// live schema and ledger are untouched; the next run's sweep retires the
|
||||
/// stale intent and the same run applies and converges.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn schema_crash_before_apply_recovers_via_sweep() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
converge_with_live_graph(dir.path()).await;
|
||||
let pre_digest = live_schema_digest(dir.path()).await;
|
||||
fs::write(dir.path().join("people.pg"), SCHEMA_V2).unwrap();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_BEFORE_SCHEMA_APPLY, "return");
|
||||
let out = apply_config_dir_with_options(
|
||||
dir.path(),
|
||||
ApplyOptions {
|
||||
actor: Some("test-actor".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(!out.ok);
|
||||
assert_eq!(out.actor.as_deref(), Some("test-actor"));
|
||||
let sidecars = recovery_sidecars(dir.path());
|
||||
assert_eq!(sidecars.len(), 1);
|
||||
let sidecar: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&sidecars[0]).unwrap()).unwrap();
|
||||
assert_eq!(sidecar["kind"], "schema_apply");
|
||||
assert_eq!(sidecar["actor"], "test-actor");
|
||||
// Nothing moved.
|
||||
assert_eq!(live_schema_digest(dir.path()).await, pre_digest);
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(recovered.converged);
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
assert_ne!(live_schema_digest(dir.path()).await, pre_digest);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Engine apply fails after cluster preview and sidecar creation, but before
|
||||
/// the graph manifest moves. The defensive cleanup proof should remove the
|
||||
/// cluster sidecar immediately so a pre-movement error cannot brick boot.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn schema_apply_error_before_graph_movement_removes_sidecar() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
converge_with_live_graph(dir.path()).await;
|
||||
let pre_digest = live_schema_digest(dir.path()).await;
|
||||
fs::write(dir.path().join("people.pg"), SCHEMA_V2).unwrap();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph::failpoints::names::SCHEMA_APPLY_BEFORE_STAGING_WRITE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(
|
||||
out.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "schema_apply_failed"),
|
||||
"{:?}",
|
||||
out.diagnostics
|
||||
);
|
||||
assert_eq!(live_schema_digest(dir.path()).await, pre_digest);
|
||||
assert!(
|
||||
recovery_sidecars(dir.path()).is_empty(),
|
||||
"{:?}",
|
||||
recovery_sidecars(dir.path())
|
||||
);
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok && recovered.converged, "{recovered:?}");
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
assert_ne!(live_schema_digest(dir.path()).await, pre_digest);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Engine apply fails after the graph manifest moved. The cluster cannot
|
||||
/// prove this is a pre-movement failure, so the sidecar must survive for
|
||||
/// explicit recovery/quarantine instead of being cleaned up defensively.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn schema_apply_error_after_graph_movement_keeps_sidecar() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
converge_with_live_graph(dir.path()).await;
|
||||
let pre_digest = live_schema_digest(dir.path()).await;
|
||||
fs::write(dir.path().join("people.pg"), SCHEMA_V2).unwrap();
|
||||
let desired = validate_config_dir(dir.path());
|
||||
let v2_digest = desired.resource_digests["schema.knowledge"].clone();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph::failpoints::names::SCHEMA_APPLY_AFTER_MANIFEST_COMMIT, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(
|
||||
out.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "schema_apply_failed"),
|
||||
"{:?}",
|
||||
out.diagnostics
|
||||
);
|
||||
// Read-only opens do not run engine schema-state recovery, so the
|
||||
// schema file still reads as the old digest even though the manifest
|
||||
// has moved. The cluster sidecar must remain because movement was
|
||||
// detected by the fallback manifest-version proof.
|
||||
assert_eq!(live_schema_digest(dir.path()).await, pre_digest);
|
||||
let sidecars = recovery_sidecars(dir.path());
|
||||
assert_eq!(sidecars.len(), 1, "{sidecars:?}");
|
||||
let sidecar: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&sidecars[0]).unwrap()).unwrap();
|
||||
assert_eq!(sidecar["kind"], "schema_apply");
|
||||
assert!(sidecar["expected_manifest_version"].is_null(), "{sidecar}");
|
||||
}
|
||||
|
||||
let uri = dir.path().join("graphs/knowledge.omni");
|
||||
let db = Omnigraph::open(uri.to_string_lossy().as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.schema_source().as_str(),
|
||||
SCHEMA_V2,
|
||||
"read-write open should complete engine schema-state recovery"
|
||||
);
|
||||
drop(db);
|
||||
assert_eq!(live_schema_digest(dir.path()).await, v2_digest);
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(
|
||||
recovered
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "cluster_recovery_rolled_forward")
|
||||
);
|
||||
assert!(recovered.converged);
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Crash after the engine schema apply, before the state CAS: the manifest
|
||||
/// moved, the ledger is stale, nothing acknowledged; the next run's sweep
|
||||
/// rolls the ledger forward with an audit entry and the run converges.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn schema_crash_after_apply_rolls_state_forward() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
converge_with_live_graph(dir.path()).await;
|
||||
fs::write(dir.path().join("people.pg"), SCHEMA_V2).unwrap();
|
||||
let state_before = fs::read(state_path(dir.path())).unwrap();
|
||||
let desired = validate_config_dir(dir.path());
|
||||
let v2_digest = desired.resource_digests["schema.knowledge"].clone();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_AFTER_SCHEMA_APPLY, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(!out.state_written);
|
||||
// The live schema moved; the ledger is byte-identical (no ack).
|
||||
assert_eq!(live_schema_digest(dir.path()).await, v2_digest);
|
||||
assert_eq!(fs::read(state_path(dir.path())).unwrap(), state_before);
|
||||
let sidecars = recovery_sidecars(dir.path());
|
||||
assert_eq!(sidecars.len(), 1);
|
||||
let sidecar: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&sidecars[0]).unwrap()).unwrap();
|
||||
assert!(
|
||||
sidecar["expected_manifest_version"].is_number(),
|
||||
"{sidecar}"
|
||||
);
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(
|
||||
recovered
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "cluster_recovery_rolled_forward")
|
||||
);
|
||||
assert!(recovered.converged);
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
let state: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(state_path(dir.path())).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
state["applied_revision"]["resources"]["schema.knowledge"]["digest"],
|
||||
v2_digest
|
||||
);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Seed: converged state + a stale `old` graph subtree with a real root and
|
||||
/// a valid approval for its delete. Returns the approval id.
|
||||
async fn seed_approved_delete(dir: &Path) -> String {
|
||||
let digests = seed_applyable_state(dir);
|
||||
let graph_digest = digests["graph.knowledge"].clone();
|
||||
let schema_digest = digests["schema.knowledge"].clone();
|
||||
let state_dir = dir.join("__cluster");
|
||||
fs::write(
|
||||
state_dir.join("state.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"version": 1,
|
||||
"state_revision": 1,
|
||||
"applied_revision": {{
|
||||
"resources": {{
|
||||
"graph.knowledge": {{ "digest": "{graph_digest}" }},
|
||||
"schema.knowledge": {{ "digest": "{schema_digest}" }},
|
||||
"graph.old": {{ "digest": "3333" }},
|
||||
"schema.old": {{ "digest": "4444" }}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let root = dir.join("graphs/old.omni");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
fs::write(root.join("_schema.pg"), "stale").unwrap();
|
||||
let approved = approve_config_dir(dir, "graph.old", "test-actor").await;
|
||||
assert!(approved.ok, "{:?}", approved.diagnostics);
|
||||
approved.approval_id.unwrap()
|
||||
}
|
||||
|
||||
/// Crash before the removal: root intact, approval unconsumed, no ack; the
|
||||
/// next run retires the stale intent (row 8) and the still-approved delete
|
||||
/// completes in the same run.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_crash_before_removal_reproposes() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
let approval_id = seed_approved_delete(dir.path()).await;
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_BEFORE_GRAPH_DELETE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(dir.path().join("graphs/old.omni").exists());
|
||||
assert_eq!(recovery_sidecars(dir.path()).len(), 1);
|
||||
// The approval is untouched (file unconsumed).
|
||||
let artifact: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(
|
||||
dir.path()
|
||||
.join("__cluster/approvals")
|
||||
.join(format!("{approval_id}.json")),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(artifact["consumed_at"].is_null());
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(
|
||||
recovered
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "graph_delete_incomplete")
|
||||
);
|
||||
assert!(recovered.converged);
|
||||
assert!(!dir.path().join("graphs/old.omni").exists());
|
||||
assert!(recovery_sidecars(dir.path()).is_empty());
|
||||
scenario.teardown();
|
||||
}
|
||||
|
||||
/// Crash after the removal, before the state CAS: root gone, ledger stale,
|
||||
/// nothing acknowledged; the next run's sweep rolls the tombstone forward,
|
||||
/// consumes the approval the sidecar carries, and audits the recovery.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn delete_crash_after_removal_rolls_forward() {
|
||||
let scenario = FailScenario::setup();
|
||||
let dir = fixture();
|
||||
let approval_id = seed_approved_delete(dir.path()).await;
|
||||
let state_before = fs::read(state_path(dir.path())).unwrap();
|
||||
|
||||
{
|
||||
let _failpoint = ScopedFailPoint::new(omnigraph_cluster::failpoints::names::CLUSTER_APPLY_AFTER_GRAPH_DELETE, "return");
|
||||
let out = apply_config_dir(dir.path()).await;
|
||||
assert!(!out.ok);
|
||||
assert!(!out.state_written);
|
||||
assert!(!dir.path().join("graphs/old.omni").exists());
|
||||
assert_eq!(fs::read(state_path(dir.path())).unwrap(), state_before);
|
||||
let sidecars = recovery_sidecars(dir.path());
|
||||
assert_eq!(sidecars.len(), 1);
|
||||
let sidecar: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&sidecars[0]).unwrap()).unwrap();
|
||||
assert_eq!(sidecar["approval_id"], approval_id.as_str());
|
||||
}
|
||||
|
||||
let recovered = apply_config_dir(dir.path()).await;
|
||||
assert!(recovered.ok, "{:?}", recovered.diagnostics);
|
||||
assert!(
|
||||
recovered
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "cluster_recovery_rolled_forward")
|
||||
);
|
||||
assert!(recovered.converged);
|
||||
let state: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(state_path(dir.path())).unwrap()).unwrap();
|
||||
assert_eq!(state["observations"]["graph.old"]["kind"], "tombstone");
|
||||
assert!(state["approval_records"][&approval_id]["consumed_at"].is_string());
|
||||
assert!(
|
||||
state["recovery_records"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|record| record["kind"] == "graph_delete")
|
||||
);
|
||||
scenario.teardown();
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
//! Cluster-on-object-storage end-to-end (RFC-006): the full control-plane
|
||||
//! lifecycle with `storage: s3://…` — import, apply (graph roots + catalog
|
||||
//! on the bucket), serving snapshots from both the config dir and the bare
|
||||
//! storage URI, schema evolution, and the approved delete (prefix removal).
|
||||
//!
|
||||
//! Gated like every S3 suite: skips unless `OMNIGRAPH_S3_TEST_BUCKET` is
|
||||
//! set (CI runs it against containerized RustFS; locally use the RustFS
|
||||
//! binary + `AWS_*` env, see docs/dev/testing.md).
|
||||
//!
|
||||
//! Runtime flavor is multi_thread on purpose: the state-lock guard's
|
||||
//! drop-time release uses block_in_place on object stores, which is the
|
||||
//! production (CLI) runtime shape — and the lock-release regression this
|
||||
//! suite pins (a spawned delete dying with a short-lived runtime) only
|
||||
//! reproduces realistically under it.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
|
||||
use omnigraph_cluster::{
|
||||
apply_config_dir, import_config_dir, read_serving_snapshot,
|
||||
read_serving_snapshot_from_storage, status_config_dir, validate_config_dir,
|
||||
};
|
||||
use ulid::Ulid;
|
||||
|
||||
const SCHEMA_V1: &str = "node Person {\n name: String @key\n}\n";
|
||||
const SCHEMA_V2: &str = "node Person {\n name: String @key\n title: String?\n}\n";
|
||||
const FIND_PERSON_GQ: &str = "query find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n";
|
||||
const POLICY_YAML: &str = r#"
|
||||
version: 1
|
||||
actors:
|
||||
- id: act-admin
|
||||
roles: [admin]
|
||||
rules:
|
||||
- effect: permit
|
||||
actions: [read, change, schema_apply, branch_create, branch_delete, branch_merge]
|
||||
roles: [admin]
|
||||
"#;
|
||||
|
||||
/// Unique per-run storage root under the test bucket, or None to skip.
|
||||
fn s3_storage_root(suite: &str) -> Option<String> {
|
||||
let bucket = env::var("OMNIGRAPH_S3_TEST_BUCKET").ok()?;
|
||||
Some(format!("s3://{bucket}/cluster-e2e/{suite}-{}", Ulid::new()))
|
||||
}
|
||||
|
||||
fn write_cluster_fixture(dir: &std::path::Path, storage_root: &str, schema: &str) {
|
||||
fs::write(dir.join("people.pg"), schema).unwrap();
|
||||
fs::create_dir_all(dir.join("queries")).unwrap();
|
||||
fs::write(dir.join("queries/people.gq"), FIND_PERSON_GQ).unwrap();
|
||||
fs::write(dir.join("intel.policy.yaml"), POLICY_YAML).unwrap();
|
||||
fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
format!(
|
||||
r#"version: 1
|
||||
storage: {storage_root}
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: people.pg
|
||||
queries: queries/
|
||||
policies:
|
||||
intel:
|
||||
file: intel.policy.yaml
|
||||
applies_to: [graph.knowledge]
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn s3_cluster_full_lifecycle_import_apply_serve_evolve_delete() {
|
||||
let Some(root) = s3_storage_root("lifecycle") else {
|
||||
eprintln!("skipping s3 cluster e2e: OMNIGRAPH_S3_TEST_BUCKET is not set");
|
||||
return;
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_cluster_fixture(dir.path(), &root, SCHEMA_V1);
|
||||
|
||||
// validate is config-only and must pass before any bucket I/O.
|
||||
let validate = validate_config_dir(dir.path());
|
||||
assert!(validate.ok, "{:?}", validate.diagnostics);
|
||||
|
||||
let import = import_config_dir(dir.path()).await;
|
||||
assert!(import.ok, "{:?}", import.diagnostics);
|
||||
|
||||
// The lock-release regression (caught live on the first smoke): the
|
||||
// guard's drop must COMPLETE its bucket delete before the command
|
||||
// returns — a follow-up command finding `state_lock_held` means the
|
||||
// release was spawned into a dying runtime.
|
||||
let status = status_config_dir(dir.path()).await;
|
||||
assert!(status.ok, "{:?}", status.diagnostics);
|
||||
assert!(
|
||||
!status.state_observations.locked,
|
||||
"import leaked the state lock on the bucket: {:?}",
|
||||
status.state_observations
|
||||
);
|
||||
|
||||
let apply = apply_config_dir(dir.path()).await;
|
||||
assert!(apply.ok && apply.converged, "{:?}", apply.diagnostics);
|
||||
|
||||
// Nothing stored locally: the config dir holds only declared sources.
|
||||
assert!(!dir.path().join("__cluster").exists());
|
||||
assert!(!dir.path().join("graphs").exists());
|
||||
|
||||
// Serving snapshot resolves through cluster.yaml's storage: key…
|
||||
let via_config = read_serving_snapshot(dir.path()).await.unwrap();
|
||||
assert_eq!(via_config.graphs.len(), 1);
|
||||
let graph_root = via_config.graphs[0].root.to_string_lossy().to_string();
|
||||
assert!(
|
||||
graph_root.starts_with("s3://") && graph_root.ends_with("graphs/knowledge.omni"),
|
||||
"{graph_root}"
|
||||
);
|
||||
assert_eq!(via_config.queries.len(), 1);
|
||||
assert_eq!(via_config.policies.len(), 1);
|
||||
assert!(
|
||||
via_config.policies[0].source.contains("act-admin"),
|
||||
"policy must carry verified content, not a path"
|
||||
);
|
||||
|
||||
// …and config-free, straight from the bucket URI (the deployment
|
||||
// payoff: a server needs only the URI and credentials).
|
||||
let via_uri = read_serving_snapshot_from_storage(&root).await.unwrap();
|
||||
assert_eq!(via_uri.graphs.len(), 1);
|
||||
assert_eq!(
|
||||
via_uri.graphs[0].root.to_string_lossy(),
|
||||
via_config.graphs[0].root.to_string_lossy()
|
||||
);
|
||||
assert_eq!(via_uri.policies.len(), 1);
|
||||
|
||||
// Schema evolution converges on the bucket.
|
||||
write_cluster_fixture(dir.path(), &root, SCHEMA_V2);
|
||||
let evolve = apply_config_dir(dir.path()).await;
|
||||
assert!(evolve.ok && evolve.converged, "{:?}", evolve.diagnostics);
|
||||
|
||||
// Approved delete: drop the graph from the config; the plan demands an
|
||||
// approval, the approved apply prefix-deletes the bucket root.
|
||||
fs::write(
|
||||
dir.path().join("cluster.yaml"),
|
||||
format!("version: 1\nstorage: {root}\ngraphs: {{}}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let plan = omnigraph_cluster::plan_config_dir(dir.path()).await;
|
||||
assert!(plan.ok, "{:?}", plan.diagnostics);
|
||||
let approval = plan
|
||||
.approvals_required
|
||||
.first()
|
||||
.expect("graph delete requires approval");
|
||||
let approve = omnigraph_cluster::approve_config_dir(
|
||||
dir.path(),
|
||||
&approval.resource,
|
||||
"e2e-operator",
|
||||
)
|
||||
.await;
|
||||
assert!(approve.ok, "{:?}", approve.diagnostics);
|
||||
let delete = apply_config_dir(dir.path()).await;
|
||||
assert!(delete.ok && delete.converged, "{:?}", delete.diagnostics);
|
||||
|
||||
let after = read_serving_snapshot_from_storage(&root).await;
|
||||
assert!(
|
||||
after.is_err(),
|
||||
"an empty cluster must refuse to serve, got {after:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "omnigraph-compiler"
|
||||
version = "0.7.2"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "Schema/query compiler for Omnigraph. Zero Lance dependency."
|
||||
license = "MIT"
|
||||
|
|
@ -20,5 +20,10 @@ pest_derive = { workspace = true }
|
|||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
ahash = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::Arc;
|
|||
|
||||
use arrow_schema::{DataType, Field, Schema, SchemaRef};
|
||||
|
||||
use crate::error::{CompilerError, Result};
|
||||
use crate::error::{NanoError, Result};
|
||||
use crate::schema::ast::{Cardinality, Constraint, ConstraintBound, SchemaDecl, SchemaFile};
|
||||
use crate::types::{PropType, ScalarType};
|
||||
|
||||
|
|
@ -26,15 +26,6 @@ pub struct InterfaceType {
|
|||
pub properties: HashMap<String, PropType>,
|
||||
}
|
||||
|
||||
/// The `@embed` binding for a vector property: its source text property and,
|
||||
/// optionally, the embedding model recorded by `@embed("source", model="…")`.
|
||||
/// The model is what the query-time same-space check validates against.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EmbedSource {
|
||||
pub source: String,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeType {
|
||||
pub name: String,
|
||||
|
|
@ -51,8 +42,8 @@ pub struct NodeType {
|
|||
pub range_constraints: Vec<RangeConstraint>,
|
||||
/// Regex check constraints
|
||||
pub check_constraints: Vec<CheckConstraint>,
|
||||
/// Maps @embed target property -> its source text property + recorded model.
|
||||
pub embed_sources: HashMap<String, EmbedSource>,
|
||||
/// Maps @embed target property -> source text property
|
||||
pub embed_sources: HashMap<String, String>,
|
||||
pub blob_properties: HashSet<String>,
|
||||
pub arrow_schema: SchemaRef,
|
||||
}
|
||||
|
|
@ -151,7 +142,7 @@ pub fn build_catalog(schema: &SchemaFile) -> Result<Catalog> {
|
|||
for decl in &schema.declarations {
|
||||
if let SchemaDecl::Node(node) = decl {
|
||||
if node_types.contains_key(&node.name) {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"duplicate node type: {}",
|
||||
node.name
|
||||
)));
|
||||
|
|
@ -165,18 +156,14 @@ pub fn build_catalog(schema: &SchemaFile) -> Result<Catalog> {
|
|||
if matches!(prop.prop_type.scalar, ScalarType::Blob) {
|
||||
blob_properties.insert(prop.name.clone());
|
||||
}
|
||||
// Extract @embed: the source text property (positional) and the
|
||||
// optional recorded model (the `model` kwarg).
|
||||
if let Some(ann) = prop.annotations.iter().find(|ann| ann.name == "embed") {
|
||||
if let Some(source) = ann.value.clone() {
|
||||
embed_sources.insert(
|
||||
prop.name.clone(),
|
||||
EmbedSource {
|
||||
source,
|
||||
model: ann.kwargs.get("model").cloned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// Extract @embed from property annotations (stays as annotation)
|
||||
if let Some(source_prop) = prop
|
||||
.annotations
|
||||
.iter()
|
||||
.find(|ann| ann.name == "embed")
|
||||
.and_then(|ann| ann.value.clone())
|
||||
{
|
||||
embed_sources.insert(prop.name.clone(), source_prop);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,19 +237,19 @@ pub fn build_catalog(schema: &SchemaFile) -> Result<Catalog> {
|
|||
for decl in &schema.declarations {
|
||||
if let SchemaDecl::Edge(edge) = decl {
|
||||
if edge_types.contains_key(&edge.name) {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"duplicate edge type: {}",
|
||||
edge.name
|
||||
)));
|
||||
}
|
||||
if !node_types.contains_key(&edge.from_type) {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"edge {} references unknown source type: {}",
|
||||
edge.name, edge.from_type
|
||||
)));
|
||||
}
|
||||
if !node_types.contains_key(&edge.to_type) {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"edge {} references unknown target type: {}",
|
||||
edge.name, edge.to_type
|
||||
)));
|
||||
|
|
@ -302,7 +289,7 @@ pub fn build_catalog(schema: &SchemaFile) -> Result<Catalog> {
|
|||
if let Some(existing) = edge_name_index.get(&normalized_name)
|
||||
&& existing != &edge.name
|
||||
{
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"edge name collision after case folding: '{}' conflicts with '{}'",
|
||||
edge.name, existing
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::catalog::{Catalog, build_catalog};
|
||||
use crate::error::{CompilerError, Result};
|
||||
use crate::error::{NanoError, Result};
|
||||
use crate::schema::ast::{Annotation, Cardinality, Constraint, PropDecl, SchemaDecl, SchemaFile};
|
||||
use crate::types::PropType;
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ pub fn build_schema_ir(schema: &SchemaFile) -> Result<SchemaIR> {
|
|||
|
||||
pub fn build_catalog_from_ir(ir: &SchemaIR) -> Result<Catalog> {
|
||||
if ir.ir_version != SCHEMA_IR_VERSION {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"unsupported schema ir_version {} (expected {})",
|
||||
ir.ir_version, SCHEMA_IR_VERSION
|
||||
)));
|
||||
|
|
@ -167,12 +167,12 @@ pub fn build_catalog_from_ir(ir: &SchemaIR) -> Result<Catalog> {
|
|||
|
||||
pub fn schema_ir_json(ir: &SchemaIR) -> Result<String> {
|
||||
serde_json::to_string(ir)
|
||||
.map_err(|err| CompilerError::Catalog(format!("serialize schema ir error: {}", err)))
|
||||
.map_err(|err| NanoError::Catalog(format!("serialize schema ir error: {}", err)))
|
||||
}
|
||||
|
||||
pub fn schema_ir_pretty_json(ir: &SchemaIR) -> Result<String> {
|
||||
serde_json::to_string_pretty(ir)
|
||||
.map_err(|err| CompilerError::Catalog(format!("serialize schema ir error: {}", err)))
|
||||
.map_err(|err| NanoError::Catalog(format!("serialize schema ir error: {}", err)))
|
||||
}
|
||||
|
||||
pub fn schema_ir_hash(ir: &SchemaIR) -> Result<String> {
|
||||
|
|
@ -228,7 +228,7 @@ fn canonical_properties(
|
|||
.map(|property| {
|
||||
let prop_id = stable_prop_id(&owner_key, &property.name);
|
||||
if let Some(previous) = seen_prop_ids.insert(prop_id, property.name.clone()) {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"property id collision on {}: '{}' and '{}' both hash to {}",
|
||||
owner_name, previous, property.name, prop_id
|
||||
)));
|
||||
|
|
@ -308,7 +308,7 @@ fn check_type_id_collision(
|
|||
name: &str,
|
||||
) -> Result<()> {
|
||||
if let Some(previous) = seen_type_ids.insert(type_id, name.to_string()) {
|
||||
return Err(CompilerError::Catalog(format!(
|
||||
return Err(NanoError::Catalog(format!(
|
||||
"type id collision: '{}' and '{}' both hash to {}",
|
||||
previous, name, type_id
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -1137,7 +1137,6 @@ node Person @description("new") {
|
|||
annotations: vec![Annotation {
|
||||
name: "description".to_string(),
|
||||
value: Some("new".to_string()),
|
||||
kwargs: Default::default(),
|
||||
}],
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,33 +31,6 @@ fn test_build_catalog() {
|
|||
assert!(catalog.node_types.contains_key("Company"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embed_source_records_model_kwarg() {
|
||||
let schema = parse_schema(
|
||||
r#"
|
||||
node Doc {
|
||||
title: String
|
||||
embedding: Vector(3) @embed("title", model="openai/text-embedding-3-large")
|
||||
plain: Vector(3) @embed("title")
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = build_catalog(&schema).unwrap();
|
||||
let doc = catalog.node_types.get("Doc").unwrap();
|
||||
|
||||
let embedding = doc.embed_sources.get("embedding").unwrap();
|
||||
assert_eq!(embedding.source, "title");
|
||||
assert_eq!(
|
||||
embedding.model.as_deref(),
|
||||
Some("openai/text-embedding-3-large")
|
||||
);
|
||||
|
||||
let plain = doc.embed_sources.get("plain").unwrap();
|
||||
assert_eq!(plain.source, "title");
|
||||
assert_eq!(plain.model, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_lookup() {
|
||||
let schema = parse_schema(test_schema()).unwrap();
|
||||
|
|
|
|||
379
crates/omnigraph-compiler/src/embedding.rs
Normal file
379
crates/omnigraph-compiler/src/embedding.rs
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::error::{NanoError, Result};
|
||||
|
||||
const DEFAULT_EMBED_MODEL: &str = "text-embedding-3-small";
|
||||
const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 30_000;
|
||||
const DEFAULT_RETRY_ATTEMPTS: usize = 4;
|
||||
const DEFAULT_RETRY_BACKOFF_MS: u64 = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum EmbeddingTransport {
|
||||
Mock,
|
||||
OpenAi {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
http: Client,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct EmbeddingClient {
|
||||
model: String,
|
||||
retry_attempts: usize,
|
||||
retry_backoff_ms: u64,
|
||||
transport: EmbeddingTransport,
|
||||
}
|
||||
|
||||
struct EmbedCallError {
|
||||
message: String,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiEmbeddingResponse {
|
||||
data: Vec<OpenAiEmbeddingDatum>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiEmbeddingDatum {
|
||||
index: usize,
|
||||
embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiErrorEnvelope {
|
||||
error: OpenAiErrorBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiErrorBody {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl EmbeddingClient {
|
||||
pub(crate) fn from_env() -> Result<Self> {
|
||||
let model = std::env::var("NANOGRAPH_EMBED_MODEL")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_EMBED_MODEL.to_string());
|
||||
let retry_attempts =
|
||||
parse_env_usize("NANOGRAPH_EMBED_RETRY_ATTEMPTS", DEFAULT_RETRY_ATTEMPTS);
|
||||
let retry_backoff_ms =
|
||||
parse_env_u64("NANOGRAPH_EMBED_RETRY_BACKOFF_MS", DEFAULT_RETRY_BACKOFF_MS);
|
||||
|
||||
if env_flag("NANOGRAPH_EMBEDDINGS_MOCK") {
|
||||
return Ok(Self {
|
||||
model,
|
||||
retry_attempts,
|
||||
retry_backoff_ms,
|
||||
transport: EmbeddingTransport::Mock,
|
||||
});
|
||||
}
|
||||
|
||||
let api_key = std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| {
|
||||
NanoError::Execution(
|
||||
"OPENAI_API_KEY is required when an embedding call is needed".to_string(),
|
||||
)
|
||||
})?;
|
||||
let base_url = std::env::var("OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.map(|v| v.trim_end_matches('/').to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_OPENAI_BASE_URL.to_string());
|
||||
let timeout_ms = parse_env_u64("NANOGRAPH_EMBED_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
NanoError::Execution(format!("failed to initialize HTTP client: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
model,
|
||||
retry_attempts,
|
||||
retry_backoff_ms,
|
||||
transport: EmbeddingTransport::OpenAi {
|
||||
api_key,
|
||||
base_url,
|
||||
http,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mock_for_tests() -> Self {
|
||||
Self {
|
||||
model: DEFAULT_EMBED_MODEL.to_string(),
|
||||
retry_attempts: DEFAULT_RETRY_ATTEMPTS,
|
||||
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
transport: EmbeddingTransport::Mock,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub(crate) async fn embed_text(&self, input: &str, expected_dim: usize) -> Result<Vec<f32>> {
|
||||
let mut vectors = self.embed_texts(&[input.to_string()], expected_dim).await?;
|
||||
vectors.pop().ok_or_else(|| {
|
||||
NanoError::Execution("embedding provider returned no vector".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn embed_texts(
|
||||
&self,
|
||||
inputs: &[String],
|
||||
expected_dim: usize,
|
||||
) -> Result<Vec<Vec<f32>>> {
|
||||
if expected_dim == 0 {
|
||||
return Err(NanoError::Execution(
|
||||
"embedding dimension must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
if inputs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
match &self.transport {
|
||||
EmbeddingTransport::Mock => Ok(inputs
|
||||
.iter()
|
||||
.map(|input| mock_embedding(input, expected_dim))
|
||||
.collect()),
|
||||
EmbeddingTransport::OpenAi { .. } => {
|
||||
self.embed_texts_openai_with_retry(inputs, expected_dim)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn embed_texts_openai_with_retry(
|
||||
&self,
|
||||
inputs: &[String],
|
||||
expected_dim: usize,
|
||||
) -> Result<Vec<Vec<f32>>> {
|
||||
let max_attempt = self.retry_attempts.max(1);
|
||||
let mut attempt = 0usize;
|
||||
loop {
|
||||
attempt += 1;
|
||||
match self.embed_texts_openai_once(inputs, expected_dim).await {
|
||||
Ok(vectors) => return Ok(vectors),
|
||||
Err(err) => {
|
||||
if !err.retryable || attempt >= max_attempt {
|
||||
return Err(NanoError::Execution(err.message));
|
||||
}
|
||||
let shift = (attempt - 1).min(10) as u32;
|
||||
let delay = self.retry_backoff_ms.saturating_mul(1u64 << shift);
|
||||
sleep(Duration::from_millis(delay)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn embed_texts_openai_once(
|
||||
&self,
|
||||
inputs: &[String],
|
||||
expected_dim: usize,
|
||||
) -> std::result::Result<Vec<Vec<f32>>, EmbedCallError> {
|
||||
let (api_key, base_url, http) = match &self.transport {
|
||||
EmbeddingTransport::OpenAi {
|
||||
api_key,
|
||||
base_url,
|
||||
http,
|
||||
} => (api_key, base_url, http),
|
||||
EmbeddingTransport::Mock => unreachable!("mock transport should not call OpenAI"),
|
||||
};
|
||||
|
||||
let request = serde_json::json!({
|
||||
"model": self.model,
|
||||
"input": inputs,
|
||||
"dimensions": expected_dim,
|
||||
});
|
||||
let url = format!("{}/embeddings", base_url);
|
||||
let response = http
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let response = match response {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
let retryable = err.is_timeout() || err.is_connect() || err.is_request();
|
||||
return Err(EmbedCallError {
|
||||
message: format!("embedding request failed: {}", err),
|
||||
retryable,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let body = match response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(err) => {
|
||||
return Err(EmbedCallError {
|
||||
message: format!(
|
||||
"embedding response read failed (status {}): {}",
|
||||
status, err
|
||||
),
|
||||
retryable: status.is_server_error() || status.as_u16() == 429,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
let message = parse_openai_error_message(&body).unwrap_or_else(|| body.clone());
|
||||
return Err(EmbedCallError {
|
||||
message: format!(
|
||||
"embedding request failed with status {}: {}",
|
||||
status, message
|
||||
),
|
||||
retryable: status.is_server_error() || status.as_u16() == 429,
|
||||
});
|
||||
}
|
||||
|
||||
let mut parsed: OpenAiEmbeddingResponse =
|
||||
serde_json::from_str(&body).map_err(|err| EmbedCallError {
|
||||
message: format!("embedding response decode failed: {}", err),
|
||||
retryable: false,
|
||||
})?;
|
||||
|
||||
if parsed.data.len() != inputs.len() {
|
||||
return Err(EmbedCallError {
|
||||
message: format!(
|
||||
"embedding response size mismatch: expected {}, got {}",
|
||||
inputs.len(),
|
||||
parsed.data.len()
|
||||
),
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
|
||||
parsed.data.sort_by_key(|item| item.index);
|
||||
let mut vectors = Vec::with_capacity(parsed.data.len());
|
||||
for (idx, item) in parsed.data.into_iter().enumerate() {
|
||||
if item.index != idx {
|
||||
return Err(EmbedCallError {
|
||||
message: format!(
|
||||
"embedding response index mismatch at position {}: got {}",
|
||||
idx, item.index
|
||||
),
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
if item.embedding.len() != expected_dim {
|
||||
return Err(EmbedCallError {
|
||||
message: format!(
|
||||
"embedding dimension mismatch: expected {}, got {}",
|
||||
expected_dim,
|
||||
item.embedding.len()
|
||||
),
|
||||
retryable: false,
|
||||
});
|
||||
}
|
||||
vectors.push(item.embedding);
|
||||
}
|
||||
Ok(vectors)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_openai_error_message(body: &str) -> Option<String> {
|
||||
serde_json::from_str::<OpenAiErrorEnvelope>(body)
|
||||
.ok()
|
||||
.map(|e| e.error.message)
|
||||
.filter(|msg| !msg.trim().is_empty())
|
||||
}
|
||||
|
||||
fn parse_env_usize(name: &str, default: usize) -> usize {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn parse_env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
let s = v.trim().to_ascii_lowercase();
|
||||
s == "1" || s == "true" || s == "yes" || s == "on"
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn mock_embedding(input: &str, dim: usize) -> Vec<f32> {
|
||||
let mut seed = fnv1a64(input.as_bytes());
|
||||
let mut out = Vec::with_capacity(dim);
|
||||
for _ in 0..dim {
|
||||
seed = xorshift64(seed);
|
||||
let ratio = (seed as f64 / u64::MAX as f64) as f32;
|
||||
out.push((ratio * 2.0) - 1.0);
|
||||
}
|
||||
|
||||
let norm = out
|
||||
.iter()
|
||||
.map(|v| (*v as f64) * (*v as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt() as f32;
|
||||
if norm > f32::EPSILON {
|
||||
for value in &mut out {
|
||||
*value /= norm;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 14695981039346656037u64;
|
||||
for byte in bytes {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(1099511628211u64);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn xorshift64(mut x: u64) -> u64 {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
x
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_embeddings_are_deterministic() {
|
||||
let client = EmbeddingClient::mock_for_tests();
|
||||
let a = client.embed_text("alpha", 8).await.unwrap();
|
||||
let b = client.embed_text("alpha", 8).await.unwrap();
|
||||
let c = client.embed_text("beta", 8).await.unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_eq!(a.len(), 8);
|
||||
}
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ pub fn decode_string_literal(raw: &str) -> Result<String> {
|
|||
|
||||
let escaped = chars
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("unterminated escape sequence".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("unterminated escape sequence".to_string()))?;
|
||||
match escaped {
|
||||
'"' => decoded.push('"'),
|
||||
'\\' => decoded.push('\\'),
|
||||
|
|
@ -63,7 +63,7 @@ pub fn decode_string_literal(raw: &str) -> Result<String> {
|
|||
'r' => decoded.push('\r'),
|
||||
't' => decoded.push('\t'),
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"unsupported escape sequence: \\{}",
|
||||
other
|
||||
)));
|
||||
|
|
@ -75,7 +75,7 @@ pub fn decode_string_literal(raw: &str) -> Result<String> {
|
|||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CompilerError {
|
||||
pub enum NanoError {
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
|
||||
|
|
@ -118,16 +118,11 @@ pub enum CompilerError {
|
|||
Manifest(String),
|
||||
}
|
||||
|
||||
#[deprecated(note = "use CompilerError")]
|
||||
pub type NanoError = CompilerError;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, CompilerError>;
|
||||
pub type Result<T> = std::result::Result<T, NanoError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::{CompilerError, SourceSpan, decode_string_literal, render_span};
|
||||
use super::{SourceSpan, decode_string_literal, render_span};
|
||||
|
||||
#[test]
|
||||
fn source_span_preserves_zero_width() {
|
||||
|
|
@ -148,77 +143,4 @@ mod tests {
|
|||
let decoded = decode_string_literal("\"a\\n\\r\\t\\\\\\\"b\"").unwrap();
|
||||
assert_eq!(decoded, "a\n\r\t\\\"b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_error_parse_display_is_stable() {
|
||||
let err = CompilerError::Parse("bad token".to_string());
|
||||
assert_eq!(err.to_string(), "parse error: bad token");
|
||||
}
|
||||
|
||||
#[allow(deprecated)]
|
||||
#[test]
|
||||
fn legacy_nano_error_alias_constructs_variants() {
|
||||
let err = super::NanoError::Parse("bad token".to_string());
|
||||
assert_eq!(err.to_string(), "parse error: bad token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_name_is_confined_to_alias_and_compatibility_test() {
|
||||
let legacy_name = ["Nano", "Error"].concat();
|
||||
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.expect("compiler crate should live under crates/");
|
||||
let allowed_file = workspace_root.join("crates/omnigraph-compiler/src/error.rs");
|
||||
let mut offenders = Vec::new();
|
||||
|
||||
visit_rs_files(workspace_root, &mut |path| {
|
||||
let text = std::fs::read_to_string(path).expect("source file should be readable");
|
||||
let count = text.matches(&legacy_name).count();
|
||||
if path == allowed_file {
|
||||
if count != 2 {
|
||||
offenders.push(format!(
|
||||
"{} contains {count} legacy-name occurrences; expected exactly 2",
|
||||
display_path(workspace_root, path)
|
||||
));
|
||||
}
|
||||
} else if count > 0 {
|
||||
offenders.push(format!(
|
||||
"{} contains {count} legacy-name occurrence(s)",
|
||||
display_path(workspace_root, path)
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"legacy compiler error name should stay compatibility-only:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
fn visit_rs_files(dir: &Path, visit: &mut impl FnMut(&Path)) {
|
||||
for entry in std::fs::read_dir(dir).expect("source directory should be readable") {
|
||||
let entry = entry.expect("source entry should be readable");
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if matches!(
|
||||
path.file_name().and_then(|name| name.to_str()),
|
||||
Some(".git" | "target")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
visit_rs_files(&path, visit);
|
||||
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
|
||||
visit(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_path(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub fn lower_query(
|
|||
type_ctx: &TypeContext,
|
||||
) -> Result<QueryIR> {
|
||||
if !query.mutations.is_empty() {
|
||||
return Err(crate::error::CompilerError::Plan(
|
||||
return Err(crate::error::NanoError::Plan(
|
||||
"cannot lower mutation query with read-query lowerer".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ pub fn lower_query(
|
|||
|
||||
pub fn lower_mutation_query(query: &QueryDecl) -> Result<MutationIR> {
|
||||
if query.mutations.is_empty() {
|
||||
return Err(crate::error::CompilerError::Plan(
|
||||
return Err(crate::error::NanoError::Plan(
|
||||
"query does not contain a mutation body".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -261,7 +261,7 @@ fn lower_clauses(
|
|||
let edge = catalog
|
||||
.lookup_edge_by_name(&traversal.edge_name)
|
||||
.ok_or_else(|| {
|
||||
crate::error::CompilerError::Plan(format!(
|
||||
crate::error::NanoError::Plan(format!(
|
||||
"lowering traversal referenced missing edge '{}' after typecheck",
|
||||
traversal.edge_name
|
||||
))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod catalog;
|
||||
pub mod embedding;
|
||||
pub mod error;
|
||||
pub mod ir;
|
||||
pub mod json_output;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use pest::error::InputLocation;
|
|||
use pest_derive::Parser;
|
||||
|
||||
use crate::error::{
|
||||
CompilerError, ParseDiagnostic, Result, SourceSpan, decode_string_literal, render_span,
|
||||
NanoError, ParseDiagnostic, Result, SourceSpan, decode_string_literal, render_span,
|
||||
};
|
||||
|
||||
use super::ast::*;
|
||||
|
|
@ -13,7 +13,7 @@ use super::ast::*;
|
|||
struct QueryParser;
|
||||
|
||||
pub fn parse_query(input: &str) -> Result<QueryFile> {
|
||||
parse_query_diagnostic(input).map_err(|e| CompilerError::Parse(e.to_string()))
|
||||
parse_query_diagnostic(input).map_err(|e| NanoError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn parse_query_diagnostic(input: &str) -> std::result::Result<QueryFile, ParseDiagnostic> {
|
||||
|
|
@ -24,7 +24,7 @@ pub fn parse_query_diagnostic(input: &str) -> std::result::Result<QueryFile, Par
|
|||
if let Rule::query_file = pair.as_rule() {
|
||||
for inner in pair.into_inner() {
|
||||
if let Rule::query_decl = inner.as_rule() {
|
||||
queries.push(parse_query_decl(inner).map_err(compiler_error_to_diagnostic)?);
|
||||
queries.push(parse_query_decl(inner).map_err(nano_error_to_diagnostic)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ fn pest_error_to_diagnostic(err: pest::error::Error<Rule>) -> ParseDiagnostic {
|
|||
ParseDiagnostic::new(err.to_string(), span)
|
||||
}
|
||||
|
||||
fn compiler_error_to_diagnostic(err: CompilerError) -> ParseDiagnostic {
|
||||
fn nano_error_to_diagnostic(err: NanoError) -> ParseDiagnostic {
|
||||
ParseDiagnostic::new(err.to_string(), None)
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
|||
match annotation_name {
|
||||
"description" => {
|
||||
if description.replace(value).is_some() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"query `{}` cannot include duplicate @description annotations",
|
||||
name
|
||||
)));
|
||||
|
|
@ -79,14 +79,14 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
|||
}
|
||||
"instruction" => {
|
||||
if instruction.replace(value).is_some() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"query `{}` cannot include duplicate @instruction annotations",
|
||||
name
|
||||
)));
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"unsupported query annotation: @{}",
|
||||
other
|
||||
)));
|
||||
|
|
@ -94,9 +94,10 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
|||
}
|
||||
}
|
||||
Rule::query_body => {
|
||||
let body = item.into_inner().next().ok_or_else(|| {
|
||||
CompilerError::Parse("query body cannot be empty".to_string())
|
||||
})?;
|
||||
let body = item
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("query body cannot be empty".to_string()))?;
|
||||
match body.as_rule() {
|
||||
Rule::read_query_body => {
|
||||
for section in body.into_inner() {
|
||||
|
|
@ -126,7 +127,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
|||
let int_pair = section.into_inner().next().unwrap();
|
||||
limit =
|
||||
Some(int_pair.as_str().parse::<u64>().map_err(|e| {
|
||||
CompilerError::Parse(format!("invalid limit: {}", e))
|
||||
NanoError::Parse(format!("invalid limit: {}", e))
|
||||
})?);
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -137,7 +138,7 @@ fn parse_query_decl(pair: pest::iterators::Pair<Rule>) -> Result<QueryDecl> {
|
|||
for mutation_pair in body.into_inner() {
|
||||
if let Rule::mutation_stmt = mutation_pair.as_rule() {
|
||||
let stmt = mutation_pair.into_inner().next().ok_or_else(|| {
|
||||
CompilerError::Parse(
|
||||
NanoError::Parse(
|
||||
"mutation statement cannot be empty".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
|
@ -169,14 +170,14 @@ fn parse_query_annotation(pair: pest::iterators::Pair<Rule>) -> Result<(&'static
|
|||
let inner = pair
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("query annotation cannot be empty".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("query annotation cannot be empty".to_string()))?;
|
||||
match inner.as_rule() {
|
||||
Rule::description_annotation => {
|
||||
let value = inner
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Parse("@description requires a string literal".to_string())
|
||||
NanoError::Parse("@description requires a string literal".to_string())
|
||||
})
|
||||
.map(|value| parse_string_lit(value.as_str()))??;
|
||||
Ok(("description", value))
|
||||
|
|
@ -186,12 +187,12 @@ fn parse_query_annotation(pair: pest::iterators::Pair<Rule>) -> Result<(&'static
|
|||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Parse("@instruction requires a string literal".to_string())
|
||||
NanoError::Parse("@instruction requires a string literal".to_string())
|
||||
})
|
||||
.map(|value| parse_string_lit(value.as_str()))??;
|
||||
Ok(("instruction", value))
|
||||
}
|
||||
other => Err(CompilerError::Parse(format!(
|
||||
other => Err(NanoError::Parse(format!(
|
||||
"unexpected query annotation rule: {:?}",
|
||||
other
|
||||
))),
|
||||
|
|
@ -207,29 +208,30 @@ fn parse_param(pair: pest::iterators::Pair<Rule>) -> Result<Param> {
|
|||
let mut type_inner = type_ref.into_inner();
|
||||
let core = type_inner
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("parameter type is missing".to_string()))?;
|
||||
let base =
|
||||
match core.as_rule() {
|
||||
Rule::base_type => core.as_str().to_string(),
|
||||
Rule::list_type => {
|
||||
let inner = core.into_inner().next().ok_or_else(|| {
|
||||
CompilerError::Parse("list type missing item type".to_string())
|
||||
})?;
|
||||
format!("[{}]", inner.as_str().trim())
|
||||
}
|
||||
Rule::vector_type => {
|
||||
let vector = core.into_inner().next().ok_or_else(|| {
|
||||
CompilerError::Parse("Vector type missing dimension".to_string())
|
||||
})?;
|
||||
format!("Vector({})", vector.as_str().trim())
|
||||
}
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
"unexpected param type rule: {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
.ok_or_else(|| NanoError::Parse("parameter type is missing".to_string()))?;
|
||||
let base = match core.as_rule() {
|
||||
Rule::base_type => core.as_str().to_string(),
|
||||
Rule::list_type => {
|
||||
let inner = core
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("list type missing item type".to_string()))?;
|
||||
format!("[{}]", inner.as_str().trim())
|
||||
}
|
||||
Rule::vector_type => {
|
||||
let vector = core
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("Vector type missing dimension".to_string()))?;
|
||||
format!("Vector({})", vector.as_str().trim())
|
||||
}
|
||||
other => {
|
||||
return Err(NanoError::Parse(format!(
|
||||
"unexpected param type rule: {:?}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Param {
|
||||
name,
|
||||
|
|
@ -254,7 +256,7 @@ fn parse_clause(pair: pest::iterators::Pair<Rule>) -> Result<Clause> {
|
|||
}
|
||||
Ok(Clause::Negation(clauses))
|
||||
}
|
||||
_ => Err(CompilerError::Parse(format!(
|
||||
_ => Err(NanoError::Parse(format!(
|
||||
"unexpected clause rule: {:?}",
|
||||
inner.as_rule()
|
||||
))),
|
||||
|
|
@ -265,13 +267,13 @@ fn parse_text_search_clause(pair: pest::iterators::Pair<Rule>) -> Result<Clause>
|
|||
let inner = pair
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("text search clause cannot be empty".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("text search clause cannot be empty".to_string()))?;
|
||||
let expr = match inner.as_rule() {
|
||||
Rule::search_call => parse_search_call(inner)?,
|
||||
Rule::fuzzy_call => parse_fuzzy_call(inner)?,
|
||||
Rule::match_text_call => parse_match_text_call(inner)?,
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"unexpected text search clause rule: {:?}",
|
||||
other
|
||||
)));
|
||||
|
|
@ -323,7 +325,7 @@ fn parse_mutation_stmt(pair: pest::iterators::Pair<Rule>) -> Result<Mutation> {
|
|||
Rule::insert_stmt => parse_insert_mutation(pair).map(Mutation::Insert),
|
||||
Rule::update_stmt => parse_update_mutation(pair).map(Mutation::Update),
|
||||
Rule::delete_stmt => parse_delete_mutation(pair).map(Mutation::Delete),
|
||||
other => Err(CompilerError::Parse(format!(
|
||||
other => Err(NanoError::Parse(format!(
|
||||
"unexpected mutation statement rule: {:?}",
|
||||
other
|
||||
))),
|
||||
|
|
@ -361,7 +363,7 @@ fn parse_update_mutation(pair: pest::iterators::Pair<Rule>) -> Result<UpdateMuta
|
|||
}
|
||||
|
||||
let predicate = predicate.ok_or_else(|| {
|
||||
CompilerError::Parse("update mutation requires a where predicate".to_string())
|
||||
NanoError::Parse("update mutation requires a where predicate".to_string())
|
||||
})?;
|
||||
|
||||
Ok(UpdateMutation {
|
||||
|
|
@ -376,9 +378,7 @@ fn parse_delete_mutation(pair: pest::iterators::Pair<Rule>) -> Result<DeleteMuta
|
|||
let type_name = inner.next().unwrap().as_str().to_string();
|
||||
let predicate = inner
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Parse("delete mutation requires a where predicate".to_string())
|
||||
})
|
||||
.ok_or_else(|| NanoError::Parse("delete mutation requires a where predicate".to_string()))
|
||||
.and_then(parse_mutation_predicate)?;
|
||||
Ok(DeleteMutation {
|
||||
type_name,
|
||||
|
|
@ -416,7 +416,7 @@ fn parse_match_value(pair: pest::iterators::Pair<Rule>) -> Result<MatchValue> {
|
|||
}
|
||||
Rule::now_call => Ok(MatchValue::Now),
|
||||
Rule::literal => Ok(MatchValue::Literal(parse_literal(value_inner)?)),
|
||||
_ => Err(CompilerError::Parse(format!(
|
||||
_ => Err(NanoError::Parse(format!(
|
||||
"unexpected match value: {:?}",
|
||||
value_inner.as_rule()
|
||||
))),
|
||||
|
|
@ -436,9 +436,9 @@ fn parse_traversal(pair: pest::iterators::Pair<Rule>) -> Result<Traversal> {
|
|||
let (min, max) = parse_traversal_bounds(next)?;
|
||||
min_hops = min;
|
||||
max_hops = max;
|
||||
inner.next().ok_or_else(|| {
|
||||
CompilerError::Parse("traversal missing destination variable".to_string())
|
||||
})?
|
||||
inner
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("traversal missing destination variable".to_string()))?
|
||||
} else {
|
||||
next
|
||||
};
|
||||
|
|
@ -459,16 +459,16 @@ fn parse_traversal_bounds(pair: pest::iterators::Pair<Rule>) -> Result<(u32, Opt
|
|||
let mut inner = pair.into_inner();
|
||||
let min = inner
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("traversal bound missing min hop".to_string()))?
|
||||
.ok_or_else(|| NanoError::Parse("traversal bound missing min hop".to_string()))?
|
||||
.as_str()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| CompilerError::Parse(format!("invalid traversal min bound: {}", e)))?;
|
||||
.map_err(|e| NanoError::Parse(format!("invalid traversal min bound: {}", e)))?;
|
||||
let max = inner
|
||||
.next()
|
||||
.map(|p| {
|
||||
p.as_str()
|
||||
.parse::<u32>()
|
||||
.map_err(|e| CompilerError::Parse(format!("invalid traversal max bound: {}", e)))
|
||||
.map_err(|e| NanoError::Parse(format!("invalid traversal max bound: {}", e)))
|
||||
})
|
||||
.transpose()?;
|
||||
Ok((min, max))
|
||||
|
|
@ -507,12 +507,7 @@ fn parse_expr(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
"avg" => AggFunc::Avg,
|
||||
"min" => AggFunc::Min,
|
||||
"max" => AggFunc::Max,
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
"unknown aggregate: {}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
other => return Err(NanoError::Parse(format!("unknown aggregate: {}", other))),
|
||||
};
|
||||
let arg = parse_expr(parts.next().unwrap())?;
|
||||
Ok(Expr::Aggregate {
|
||||
|
|
@ -527,7 +522,7 @@ fn parse_expr(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
Rule::bm25_call => parse_bm25_call(inner),
|
||||
Rule::rrf_call => parse_rrf_call(inner),
|
||||
Rule::ident => Ok(Expr::AliasRef(inner.as_str().to_string())),
|
||||
_ => Err(CompilerError::Parse(format!(
|
||||
_ => Err(NanoError::Parse(format!(
|
||||
"unexpected expr rule: {:?}",
|
||||
inner.as_rule()
|
||||
))),
|
||||
|
|
@ -538,12 +533,12 @@ fn parse_search_call(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let mut args = pair.into_inner();
|
||||
let field = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("search() missing field argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("search() missing field argument".to_string()))?;
|
||||
let query = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("search() missing query argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("search() missing query argument".to_string()))?;
|
||||
if args.next().is_some() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"search() accepts exactly 2 arguments".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -557,13 +552,13 @@ fn parse_fuzzy_call(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let mut args = pair.into_inner();
|
||||
let field = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("fuzzy() missing field argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("fuzzy() missing field argument".to_string()))?;
|
||||
let query = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("fuzzy() missing query argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("fuzzy() missing query argument".to_string()))?;
|
||||
let max_edits = args.next().map(parse_expr).transpose()?.map(Box::new);
|
||||
if args.next().is_some() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"fuzzy() accepts at most 3 arguments".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -578,12 +573,12 @@ fn parse_match_text_call(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let mut args = pair.into_inner();
|
||||
let field = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("match_text() missing field argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("match_text() missing field argument".to_string()))?;
|
||||
let query = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("match_text() missing query argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("match_text() missing query argument".to_string()))?;
|
||||
if args.next().is_some() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"match_text() accepts exactly 2 arguments".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -597,12 +592,12 @@ fn parse_bm25_call(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let mut args = pair.into_inner();
|
||||
let field = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("bm25() missing field argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("bm25() missing field argument".to_string()))?;
|
||||
let query = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("bm25() missing query argument".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("bm25() missing query argument".to_string()))?;
|
||||
if args.next().is_some() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"bm25() accepts exactly 2 arguments".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -616,14 +611,14 @@ fn parse_rank_expr(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let inner = if pair.as_rule() == Rule::rank_expr {
|
||||
pair.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("rank expression cannot be empty".to_string()))?
|
||||
.ok_or_else(|| NanoError::Parse("rank expression cannot be empty".to_string()))?
|
||||
} else {
|
||||
pair
|
||||
};
|
||||
match inner.as_rule() {
|
||||
Rule::nearest_ordering => parse_nearest_ordering(inner),
|
||||
Rule::bm25_call => parse_bm25_call(inner),
|
||||
other => Err(CompilerError::Parse(format!(
|
||||
other => Err(NanoError::Parse(format!(
|
||||
"rrf() rank expression must be nearest(...) or bm25(...), got {:?}",
|
||||
other
|
||||
))),
|
||||
|
|
@ -634,13 +629,13 @@ fn parse_rrf_call(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let mut args = pair.into_inner();
|
||||
let primary = args
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("rrf() missing primary rank expression".to_string()))?;
|
||||
let secondary = args.next().ok_or_else(|| {
|
||||
CompilerError::Parse("rrf() missing secondary rank expression".to_string())
|
||||
})?;
|
||||
.ok_or_else(|| NanoError::Parse("rrf() missing primary rank expression".to_string()))?;
|
||||
let secondary = args
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("rrf() missing secondary rank expression".to_string()))?;
|
||||
let k = args.next().map(parse_expr).transpose()?.map(Box::new);
|
||||
if args.next().is_some() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"rrf() accepts at most 3 arguments".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -659,7 +654,7 @@ fn parse_comp_op(pair: pest::iterators::Pair<Rule>) -> Result<CompOp> {
|
|||
"<" => Ok(CompOp::Lt),
|
||||
">=" => Ok(CompOp::Ge),
|
||||
"<=" => Ok(CompOp::Le),
|
||||
other => Err(CompilerError::Parse(format!("unknown operator: {}", other))),
|
||||
other => Err(NanoError::Parse(format!("unknown operator: {}", other))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -678,14 +673,14 @@ fn parse_literal(pair: pest::iterators::Pair<Rule>) -> Result<Literal> {
|
|||
let n: i64 = inner
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|e| CompilerError::Parse(format!("invalid integer: {}", e)))?;
|
||||
.map_err(|e| NanoError::Parse(format!("invalid integer: {}", e)))?;
|
||||
Ok(Literal::Integer(n))
|
||||
}
|
||||
Rule::float_lit => {
|
||||
let f: f64 = inner
|
||||
.as_str()
|
||||
.parse()
|
||||
.map_err(|e| CompilerError::Parse(format!("invalid float: {}", e)))?;
|
||||
.map_err(|e| NanoError::Parse(format!("invalid float: {}", e)))?;
|
||||
Ok(Literal::Float(f))
|
||||
}
|
||||
Rule::bool_lit => {
|
||||
|
|
@ -693,7 +688,7 @@ fn parse_literal(pair: pest::iterators::Pair<Rule>) -> Result<Literal> {
|
|||
"true" => true,
|
||||
"false" => false,
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"invalid boolean literal: {}",
|
||||
other
|
||||
)));
|
||||
|
|
@ -706,9 +701,7 @@ fn parse_literal(pair: pest::iterators::Pair<Rule>) -> Result<Literal> {
|
|||
.into_inner()
|
||||
.next()
|
||||
.map(|s| parse_string_lit(s.as_str()))
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Parse("date literal requires a string".to_string())
|
||||
})?;
|
||||
.ok_or_else(|| NanoError::Parse("date literal requires a string".to_string()))?;
|
||||
Ok(Literal::Date(date_str?))
|
||||
}
|
||||
Rule::datetime_lit => {
|
||||
|
|
@ -717,7 +710,7 @@ fn parse_literal(pair: pest::iterators::Pair<Rule>) -> Result<Literal> {
|
|||
.next()
|
||||
.map(|s| parse_string_lit(s.as_str()))
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Parse("datetime literal requires a string".to_string())
|
||||
NanoError::Parse("datetime literal requires a string".to_string())
|
||||
})?;
|
||||
Ok(Literal::DateTime(dt_str?))
|
||||
}
|
||||
|
|
@ -730,7 +723,7 @@ fn parse_literal(pair: pest::iterators::Pair<Rule>) -> Result<Literal> {
|
|||
}
|
||||
Ok(Literal::List(items))
|
||||
}
|
||||
_ => Err(CompilerError::Parse(format!(
|
||||
_ => Err(NanoError::Parse(format!(
|
||||
"unexpected literal: {:?}",
|
||||
inner.as_rule()
|
||||
))),
|
||||
|
|
@ -753,14 +746,14 @@ fn parse_ordering(pair: pest::iterators::Pair<Rule>) -> Result<Ordering> {
|
|||
let mut inner = pair.into_inner();
|
||||
let first = inner
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("ordering cannot be empty".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("ordering cannot be empty".to_string()))?;
|
||||
let (expr, descending) = match first.as_rule() {
|
||||
Rule::nearest_ordering => (parse_nearest_ordering(first)?, false),
|
||||
Rule::expr => {
|
||||
let expr = parse_expr(first)?;
|
||||
let direction = inner.next().map(|p| p.as_str().to_string());
|
||||
if matches!(expr, Expr::Nearest { .. }) && direction.is_some() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"nearest() ordering does not accept asc/desc modifiers".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -768,7 +761,7 @@ fn parse_ordering(pair: pest::iterators::Pair<Rule>) -> Result<Ordering> {
|
|||
(expr, descending)
|
||||
}
|
||||
other => {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"unexpected ordering rule: {:?}",
|
||||
other
|
||||
)));
|
||||
|
|
@ -782,22 +775,22 @@ fn parse_nearest_ordering(pair: pest::iterators::Pair<Rule>) -> Result<Expr> {
|
|||
let mut inner = pair.into_inner();
|
||||
let prop = inner
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("nearest() missing property".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("nearest() missing property".to_string()))?;
|
||||
let mut prop_parts = prop.into_inner();
|
||||
let var = prop_parts
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("nearest() missing variable".to_string()))?
|
||||
.ok_or_else(|| NanoError::Parse("nearest() missing variable".to_string()))?
|
||||
.as_str();
|
||||
let variable = var.strip_prefix('$').unwrap_or(var).to_string();
|
||||
let property = prop_parts
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("nearest() missing property name".to_string()))?
|
||||
.ok_or_else(|| NanoError::Parse("nearest() missing property name".to_string()))?
|
||||
.as_str()
|
||||
.to_string();
|
||||
|
||||
let query = inner
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("nearest() missing query expression".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("nearest() missing query expression".to_string()))?;
|
||||
Ok(Expr::Nearest {
|
||||
variable,
|
||||
property,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
use arrow_schema::{DataType, Field, Schema, SchemaRef};
|
||||
|
||||
use crate::catalog::Catalog;
|
||||
use crate::error::{CompilerError, Result};
|
||||
use crate::error::{NanoError, Result};
|
||||
use crate::types::{Direction, PropType, ScalarType};
|
||||
|
||||
use super::ast::*;
|
||||
|
|
@ -82,7 +82,7 @@ pub fn typecheck_query_decl(catalog: &Catalog, query: &QueryDecl) -> Result<Chec
|
|||
|
||||
pub fn typecheck_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeContext> {
|
||||
if !query.mutations.is_empty() {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"mutation query cannot be typechecked with read-query API".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -115,14 +115,14 @@ fn parse_declared_param_types(params: &[Param]) -> Result<HashMap<String, PropTy
|
|||
let mut out = HashMap::with_capacity(params.len());
|
||||
for p in params {
|
||||
if p.name == NOW_PARAM_NAME {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"parameter name `${}` is reserved for runtime timestamp injection",
|
||||
NOW_PARAM_NAME
|
||||
)));
|
||||
}
|
||||
let prop_type =
|
||||
PropType::from_param_type_name(&p.type_name, p.nullable).ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"unknown parameter type `{}` for `${}`",
|
||||
p.type_name, p.name
|
||||
))
|
||||
|
|
@ -168,12 +168,12 @@ fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeCont
|
|||
.iter()
|
||||
.any(|ord| expr_contains_rrf_with_aliases(&ord.expr, &alias_exprs));
|
||||
if has_rrf && query.limit.is_none() {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T21: rrf ordering requires a limit clause".to_string(),
|
||||
));
|
||||
}
|
||||
if has_standalone_nearest && query.limit.is_none() {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T17: nearest ordering requires a limit clause".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -183,7 +183,7 @@ fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeCont
|
|||
.iter()
|
||||
.any(|ord| matches!(ord.expr, Expr::AliasRef(_)))
|
||||
{
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T18: alias-based ordering is not supported together with nearest in phase 1"
|
||||
.to_string(),
|
||||
));
|
||||
|
|
@ -201,7 +201,7 @@ fn typecheck_read_query(catalog: &Catalog, query: &QueryDecl) -> Result<TypeCont
|
|||
match &proj.expr {
|
||||
Expr::PropAccess { .. } | Expr::Variable(_) => {}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T9: non-aggregate expressions in an aggregate query must be \
|
||||
property accesses or variables"
|
||||
.to_string(),
|
||||
|
|
@ -221,7 +221,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
match mutation {
|
||||
Mutation::Insert(insert) => {
|
||||
if insert.assignments.is_empty() {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T10: insert mutation requires at least one assignment".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -235,7 +235,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
.properties
|
||||
.get(&assignment.property)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T11: type `{}` has no property `{}`",
|
||||
insert.type_name, assignment.property
|
||||
))
|
||||
|
|
@ -261,17 +261,17 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
continue;
|
||||
}
|
||||
|
||||
if let Some(embed) = node_type.embed_sources.get(prop_name) {
|
||||
if assigned_props.contains(embed.source.as_str()) {
|
||||
if let Some(source_prop) = node_type.embed_sources.get(prop_name) {
|
||||
if assigned_props.contains(source_prop.as_str()) {
|
||||
continue;
|
||||
}
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T12: insert for `{}` must provide non-nullable property `{}` or @embed source `{}`",
|
||||
insert.type_name, prop_name, embed.source
|
||||
insert.type_name, prop_name, source_prop
|
||||
)));
|
||||
}
|
||||
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T12: insert for `{}` must provide non-nullable property `{}`",
|
||||
insert.type_name, prop_name
|
||||
)));
|
||||
|
|
@ -308,7 +308,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
.properties
|
||||
.get(&assignment.property)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T11: type `{}` has no property `{}`",
|
||||
insert.type_name, assignment.property
|
||||
))
|
||||
|
|
@ -324,13 +324,13 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
}
|
||||
|
||||
if !has_from {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T12: insert for `{}` must provide required endpoint `from`",
|
||||
insert.type_name
|
||||
)));
|
||||
}
|
||||
if !has_to {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T12: insert for `{}` must provide required endpoint `to`",
|
||||
insert.type_name
|
||||
)));
|
||||
|
|
@ -341,7 +341,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
continue;
|
||||
}
|
||||
if !insert.assignments.iter().any(|a| &a.property == prop_name) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T12: insert for `{}` must provide non-nullable property `{}`",
|
||||
insert.type_name, prop_name
|
||||
)));
|
||||
|
|
@ -350,7 +350,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
return Ok(insert.type_name.clone());
|
||||
}
|
||||
|
||||
Err(CompilerError::Type(format!(
|
||||
Err(NanoError::Type(format!(
|
||||
"T10: unknown node/edge type `{}`",
|
||||
insert.type_name
|
||||
)))
|
||||
|
|
@ -359,19 +359,19 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
let node_type = if let Some(node_type) = catalog.node_types.get(&update.type_name) {
|
||||
node_type
|
||||
} else if catalog.edge_types.contains_key(&update.type_name) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T16: update mutation for edge type `{}` is not supported",
|
||||
update.type_name
|
||||
)));
|
||||
} else {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T10: unknown node/edge type `{}`",
|
||||
update.type_name
|
||||
)));
|
||||
};
|
||||
|
||||
if update.assignments.is_empty() {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T10: update mutation requires at least one assignment".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -383,7 +383,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
.properties
|
||||
.get(&assignment.property)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T11: type `{}` has no property `{}`",
|
||||
update.type_name, assignment.property
|
||||
))
|
||||
|
|
@ -422,7 +422,7 @@ fn typecheck_mutation(catalog: &Catalog, mutation: &Mutation, params: &[Param])
|
|||
)?;
|
||||
Ok(delete.type_name.clone())
|
||||
} else {
|
||||
Err(CompilerError::Type(format!(
|
||||
Err(NanoError::Type(format!(
|
||||
"T10: unknown node/edge type `{}`",
|
||||
delete.type_name
|
||||
)))
|
||||
|
|
@ -435,7 +435,7 @@ fn ensure_no_duplicate_assignment_names(assignments: &[MutationAssignment]) -> R
|
|||
let mut seen = std::collections::HashSet::new();
|
||||
for assignment in assignments {
|
||||
if !seen.insert(&assignment.property) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T13: duplicate assignment for property `{}`",
|
||||
assignment.property
|
||||
)));
|
||||
|
|
@ -454,13 +454,13 @@ fn typecheck_mutation_predicate(
|
|||
.properties
|
||||
.get(&predicate.property)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T11: type `{}` has no property `{}`",
|
||||
type_name, predicate.property
|
||||
))
|
||||
})?;
|
||||
if matches!(prop_type.scalar, ScalarType::Blob) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T11: blob property `{}` cannot be used in WHERE predicates",
|
||||
predicate.property
|
||||
)));
|
||||
|
|
@ -493,7 +493,7 @@ fn typecheck_edge_mutation_predicate(
|
|||
.properties
|
||||
.get(&predicate.property)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T11: type `{}` has no property `{}`",
|
||||
type_name, predicate.property
|
||||
))
|
||||
|
|
@ -517,7 +517,7 @@ fn check_match_value_type(
|
|||
MatchValue::Literal(lit) => check_literal_type(lit, expected, property),
|
||||
MatchValue::Variable(v) => {
|
||||
let Some(actual) = params.get(v) else {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T14: mutation variable `${}` must be a declared query parameter",
|
||||
v
|
||||
)));
|
||||
|
|
@ -528,7 +528,7 @@ fn check_match_value_type(
|
|||
&& matches!(actual.scalar, ScalarType::String)
|
||||
&& !actual.list);
|
||||
if !compatible {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: cannot assign/compare {} with {} for property `{}`",
|
||||
actual.display_name(),
|
||||
expected.display_name(),
|
||||
|
|
@ -543,7 +543,7 @@ fn check_match_value_type(
|
|||
|
||||
fn check_now_match_value_type(expected: &PropType, property: &str) -> Result<()> {
|
||||
if expected.list || expected.scalar != ScalarType::DateTime {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: cannot assign/compare DateTime with {} for property `{}`",
|
||||
expected.display_name(),
|
||||
property
|
||||
|
|
@ -597,7 +597,7 @@ fn typecheck_clauses(
|
|||
}
|
||||
}
|
||||
if !has_outer {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T9: negation block must reference at least one outer-bound variable"
|
||||
.to_string(),
|
||||
));
|
||||
|
|
@ -616,7 +616,7 @@ fn typecheck_binding(
|
|||
) -> Result<()> {
|
||||
// T1: binding type must exist in catalog
|
||||
if !catalog.node_types.contains_key(&binding.type_name) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T1: unknown node type `{}`",
|
||||
binding.type_name
|
||||
)));
|
||||
|
|
@ -627,14 +627,14 @@ fn typecheck_binding(
|
|||
// T2 + T3: property match fields must exist and have correct types
|
||||
for pm in &binding.prop_matches {
|
||||
let prop = node_type.properties.get(&pm.prop_name).ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T2: type `{}` has no property `{}`",
|
||||
binding.type_name, pm.prop_name
|
||||
))
|
||||
})?;
|
||||
|
||||
if matches!(prop.scalar, ScalarType::Blob) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: blob property `{}.{}` cannot be used in match patterns",
|
||||
binding.type_name, pm.prop_name
|
||||
)));
|
||||
|
|
@ -658,7 +658,7 @@ fn typecheck_binding(
|
|||
if let Some(existing) = ctx.bindings.get(&binding.variable)
|
||||
&& existing.type_name != binding.type_name
|
||||
{
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"variable `${}` already bound to type `{}`, cannot rebind to `{}`",
|
||||
binding.variable, existing.type_name, binding.type_name
|
||||
)));
|
||||
|
|
@ -680,7 +680,7 @@ fn check_binding_literal_type(lit: &Literal, expected: &PropType, property: &str
|
|||
if expected.list {
|
||||
let lit_type = literal_type(lit)?;
|
||||
if lit_type.list {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: list equality is not supported for property `{}`; use a scalar value to match list membership",
|
||||
property
|
||||
)));
|
||||
|
|
@ -688,7 +688,7 @@ fn check_binding_literal_type(lit: &Literal, expected: &PropType, property: &str
|
|||
|
||||
let expected_member = PropType::scalar(expected.scalar, expected.nullable);
|
||||
if !types_compatible(&lit_type, &expected_member) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: property `{}` has type {} but membership match got {}",
|
||||
property,
|
||||
expected.display_name(),
|
||||
|
|
@ -708,7 +708,7 @@ fn check_binding_variable_type(
|
|||
) -> Result<()> {
|
||||
if expected.list {
|
||||
if actual.list {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: list equality is not supported for property `{}`; use a scalar parameter for membership matching",
|
||||
property
|
||||
)));
|
||||
|
|
@ -716,7 +716,7 @@ fn check_binding_variable_type(
|
|||
|
||||
let expected_member = PropType::scalar(expected.scalar, expected.nullable);
|
||||
if !types_compatible(actual, &expected_member) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: cannot compare {} membership against {} for property `{}`",
|
||||
actual.display_name(),
|
||||
expected.display_name(),
|
||||
|
|
@ -727,7 +727,7 @@ fn check_binding_variable_type(
|
|||
}
|
||||
|
||||
if !types_compatible(actual, expected) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: cannot assign/compare {} with {} for property `{}`",
|
||||
actual.display_name(),
|
||||
expected.display_name(),
|
||||
|
|
@ -746,23 +746,23 @@ fn typecheck_traversal(
|
|||
let edge = catalog
|
||||
.lookup_edge_by_name(&traversal.edge_name)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!("T4: unknown edge type `{}`", traversal.edge_name))
|
||||
NanoError::Type(format!("T4: unknown edge type `{}`", traversal.edge_name))
|
||||
})?;
|
||||
|
||||
if traversal.min_hops == 0 {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T15: traversal min hop bound must be >= 1".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(max_hops) = traversal.max_hops {
|
||||
if max_hops < traversal.min_hops {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T15: invalid traversal bounds {{{},{}}}; max must be >= min",
|
||||
traversal.min_hops, max_hops
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T15: unbounded traversal is disabled; use bounded traversal {min,max}".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -784,7 +784,7 @@ fn typecheck_traversal(
|
|||
// dst should be edge.from_type
|
||||
bind_traversal_endpoint(ctx, &traversal.dst, &edge.from_type, edge)?;
|
||||
} else {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T5: variable `${}` has type `{}`, which is not an endpoint of edge `{}: {} -> {}`",
|
||||
traversal.src, src_bv.type_name, edge.name, edge.from_type, edge.to_type
|
||||
)));
|
||||
|
|
@ -798,7 +798,7 @@ fn typecheck_traversal(
|
|||
direction = Direction::In;
|
||||
bind_traversal_endpoint(ctx, &traversal.src, &edge.to_type, edge)?;
|
||||
} else {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T5: variable `${}` has type `{}`, which is not an endpoint of edge `{}: {} -> {}`",
|
||||
traversal.dst, dst_bv.type_name, edge.name, edge.from_type, edge.to_type
|
||||
)));
|
||||
|
|
@ -833,7 +833,7 @@ fn bind_traversal_endpoint(
|
|||
}
|
||||
if let Some(existing) = ctx.bindings.get(var) {
|
||||
if existing.type_name != expected_type {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T5: variable `${}` has type `{}` but edge `{}` expects `{}`",
|
||||
var, existing.type_name, edge.name, expected_type
|
||||
)));
|
||||
|
|
@ -863,27 +863,27 @@ fn typecheck_filter(
|
|||
if let (ResolvedType::Scalar(l), ResolvedType::Scalar(r)) = (&left_type, &right_type) {
|
||||
if filter.op == CompOp::Contains {
|
||||
if !l.list {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: contains requires a list property on the left, got {}",
|
||||
l.display_name()
|
||||
)));
|
||||
}
|
||||
if r.list {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T7: contains requires a scalar right operand".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(l.scalar, ScalarType::Vector(_))
|
||||
|| matches!(r.scalar, ScalarType::Vector(_))
|
||||
{
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T7: vector membership filters are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let expected_member = PropType::scalar(l.scalar, l.nullable);
|
||||
if !types_compatible(&expected_member, r) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: cannot test membership of {} in {}",
|
||||
r.display_name(),
|
||||
l.display_name()
|
||||
|
|
@ -894,29 +894,29 @@ fn typecheck_filter(
|
|||
|
||||
// T7: check type compatibility
|
||||
if l.list || r.list {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T7: list comparisons in filters are not supported; use `contains` for list membership".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(l.scalar, ScalarType::Vector(_)) || matches!(r.scalar, ScalarType::Vector(_)) {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T7: vector comparisons in filters are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
if matches!(l.scalar, ScalarType::Blob) || matches!(r.scalar, ScalarType::Blob) {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T7: blob comparisons in filters are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
if !types_compatible(l, r) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: cannot compare {} with {}",
|
||||
l.display_name(),
|
||||
r.display_name()
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T7: filter comparisons require scalar operands, got {} and {}",
|
||||
left_type.display_name(),
|
||||
right_type.display_name()
|
||||
|
|
@ -940,15 +940,15 @@ fn resolve_expr_type(
|
|||
Expr::PropAccess { variable, property } => {
|
||||
// T6: variable must be bound and property must exist
|
||||
let bv = ctx.bindings.get(variable).ok_or_else(|| {
|
||||
CompilerError::Type(format!("T6: variable `${}` is not bound", variable))
|
||||
NanoError::Type(format!("T6: variable `${}` is not bound", variable))
|
||||
})?;
|
||||
|
||||
let node_type = catalog.node_types.get(&bv.type_name).ok_or_else(|| {
|
||||
CompilerError::Type(format!("T6: type `{}` not found in catalog", bv.type_name))
|
||||
NanoError::Type(format!("T6: type `{}` not found in catalog", bv.type_name))
|
||||
})?;
|
||||
|
||||
let prop = node_type.properties.get(property).ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T6: type `{}` has no property `{}`",
|
||||
bv.type_name, property
|
||||
))
|
||||
|
|
@ -962,19 +962,19 @@ fn resolve_expr_type(
|
|||
query,
|
||||
} => {
|
||||
let node_binding = ctx.bindings.get(variable).ok_or_else(|| {
|
||||
CompilerError::Type(format!("T15: variable `${}` is not bound", variable))
|
||||
NanoError::Type(format!("T15: variable `${}` is not bound", variable))
|
||||
})?;
|
||||
let node_type = catalog
|
||||
.node_types
|
||||
.get(&node_binding.type_name)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T15: type `{}` not found in catalog",
|
||||
node_binding.type_name
|
||||
))
|
||||
})?;
|
||||
let prop_type = node_type.properties.get(property).ok_or_else(|| {
|
||||
CompilerError::Type(format!(
|
||||
NanoError::Type(format!(
|
||||
"T15: type `{}` has no property `{}`",
|
||||
node_binding.type_name, property
|
||||
))
|
||||
|
|
@ -982,7 +982,7 @@ fn resolve_expr_type(
|
|||
let vector_dim = match prop_type.scalar {
|
||||
ScalarType::Vector(dim) => dim,
|
||||
_ => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T15: nearest requires a Vector property, got {}.{}: {}",
|
||||
node_binding.type_name,
|
||||
property,
|
||||
|
|
@ -991,7 +991,7 @@ fn resolve_expr_type(
|
|||
}
|
||||
};
|
||||
if prop_type.list {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T15: nearest does not support list-wrapped vectors".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1000,7 +1000,7 @@ fn resolve_expr_type(
|
|||
&& let Some(dim) = numeric_vector_literal_dim(lit)
|
||||
{
|
||||
if dim != vector_dim {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T15: nearest vector dimension mismatch: property is Vector({}), query literal has {} elements",
|
||||
vector_dim, dim
|
||||
)));
|
||||
|
|
@ -1019,7 +1019,7 @@ fn resolve_expr_type(
|
|||
_ => unreachable!(),
|
||||
};
|
||||
if qdim != vector_dim {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T15: nearest vector dimension mismatch: property is Vector({}), query is Vector({})",
|
||||
vector_dim, qdim
|
||||
)));
|
||||
|
|
@ -1029,14 +1029,14 @@ fn resolve_expr_type(
|
|||
// query-time string embedding is supported by the runtime executor
|
||||
}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T15: nearest query must be Vector({}) or String, got {}",
|
||||
vector_dim,
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T15: nearest query must be a scalar expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1052,13 +1052,13 @@ fn resolve_expr_type(
|
|||
match field_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T19: search field must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T19: search field must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1068,13 +1068,13 @@ fn resolve_expr_type(
|
|||
match query_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T19: search query must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T19: search query must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1094,13 +1094,13 @@ fn resolve_expr_type(
|
|||
match field_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T19: fuzzy field must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T19: fuzzy field must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1110,13 +1110,13 @@ fn resolve_expr_type(
|
|||
match query_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T19: fuzzy query must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T19: fuzzy query must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1135,13 +1135,13 @@ fn resolve_expr_type(
|
|||
| ScalarType::U64
|
||||
) => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T19: fuzzy max_edits must be an integer scalar, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T19: fuzzy max_edits must be an integer scalar expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1158,13 +1158,13 @@ fn resolve_expr_type(
|
|||
match field_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T20: match_text field must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T20: match_text field must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1174,13 +1174,13 @@ fn resolve_expr_type(
|
|||
match query_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T20: match_text query must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T20: match_text query must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1196,13 +1196,13 @@ fn resolve_expr_type(
|
|||
match field_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T20: bm25 field must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T20: bm25 field must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1212,13 +1212,13 @@ fn resolve_expr_type(
|
|||
match query_type {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::String && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T20: bm25 query must be String, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T20: bm25 query must be a scalar String expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1235,12 +1235,12 @@ fn resolve_expr_type(
|
|||
k,
|
||||
} => {
|
||||
if !matches!(primary.as_ref(), Expr::Nearest { .. } | Expr::Bm25 { .. }) {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T21: rrf primary expression must be nearest(...) or bm25(...)".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(secondary.as_ref(), Expr::Nearest { .. } | Expr::Bm25 { .. }) {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T21: rrf secondary expression must be nearest(...) or bm25(...)".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1252,13 +1252,13 @@ fn resolve_expr_type(
|
|||
match ty {
|
||||
ResolvedType::Scalar(s) if s.scalar == ScalarType::F64 && !s.list => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T21: rrf rank expressions must evaluate to F64, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T21: rrf rank expressions must be scalar numeric expressions"
|
||||
.to_string(),
|
||||
));
|
||||
|
|
@ -1279,13 +1279,13 @@ fn resolve_expr_type(
|
|||
| ScalarType::U64
|
||||
) => {}
|
||||
ResolvedType::Scalar(s) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T21: rrf k must be an integer scalar, got {}",
|
||||
s.display_name()
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T21: rrf k must be an integer scalar expression".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1293,7 +1293,7 @@ fn resolve_expr_type(
|
|||
if let Expr::Literal(Literal::Integer(v)) = k_expr.as_ref()
|
||||
&& *v <= 0
|
||||
{
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"T21: rrf k must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1311,7 +1311,7 @@ fn resolve_expr_type(
|
|||
} else if let Some(bv) = ctx.bindings.get(name) {
|
||||
Ok(ResolvedType::Node(bv.type_name.clone()))
|
||||
} else {
|
||||
Err(CompilerError::Type(format!(
|
||||
Err(NanoError::Type(format!(
|
||||
"variable `${}` is not bound",
|
||||
name
|
||||
)))
|
||||
|
|
@ -1327,7 +1327,7 @@ fn resolve_expr_type(
|
|||
if let ResolvedType::Scalar(s) = &arg_type
|
||||
&& (s.list || !s.scalar.is_numeric())
|
||||
{
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T8: {} requires numeric type, got {}",
|
||||
func,
|
||||
s.display_name()
|
||||
|
|
@ -1338,7 +1338,7 @@ fn resolve_expr_type(
|
|||
if let ResolvedType::Scalar(s) = &arg_type
|
||||
&& (s.list || (!s.scalar.is_numeric() && s.scalar != ScalarType::String))
|
||||
{
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T8: {} requires numeric or string type, got {}",
|
||||
func,
|
||||
s.display_name()
|
||||
|
|
@ -1420,7 +1420,7 @@ fn resolved_type_to_field_shape(
|
|||
ResolvedType::Scalar(prop_type) => Ok((prop_type.to_arrow(), prop_type.nullable)),
|
||||
ResolvedType::Node(type_name) => {
|
||||
let node_type = catalog.node_types.get(type_name).ok_or_else(|| {
|
||||
CompilerError::Type(format!("type `{}` not found in catalog", type_name))
|
||||
NanoError::Type(format!("type `{}` not found in catalog", type_name))
|
||||
})?;
|
||||
let fields: Vec<Field> = node_type
|
||||
.arrow_schema
|
||||
|
|
@ -1450,14 +1450,14 @@ fn literal_type(lit: &Literal) -> Result<PropType> {
|
|||
}
|
||||
let first = literal_type(&items[0])?;
|
||||
if first.list {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"nested list literals are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
for item in items.iter().skip(1) {
|
||||
let item_type = literal_type(item)?;
|
||||
if item_type.list || !types_compatible(&first, &item_type) {
|
||||
return Err(CompilerError::Type(
|
||||
return Err(NanoError::Type(
|
||||
"list literal elements must share a compatible scalar type".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -1473,7 +1473,7 @@ fn check_literal_type(lit: &Literal, expected: &PropType, prop_name: &str) -> Re
|
|||
return if expected.nullable {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CompilerError::Type(format!(
|
||||
Err(NanoError::Type(format!(
|
||||
"T3: property `{}` is non-nullable but got null",
|
||||
prop_name
|
||||
)))
|
||||
|
|
@ -1487,7 +1487,7 @@ fn check_literal_type(lit: &Literal, expected: &PropType, prop_name: &str) -> Re
|
|||
if actual_dim == expected_dim {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: property `{}` has type Vector({}) but got vector literal with {} elements",
|
||||
prop_name, expected_dim, actual_dim
|
||||
)));
|
||||
|
|
@ -1495,7 +1495,7 @@ fn check_literal_type(lit: &Literal, expected: &PropType, prop_name: &str) -> Re
|
|||
|
||||
let lit_type = literal_type(lit)?;
|
||||
if !types_compatible(&lit_type, expected) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: property `{}` has type {} but got {}",
|
||||
prop_name,
|
||||
expected.display_name(),
|
||||
|
|
@ -1507,7 +1507,7 @@ fn check_literal_type(lit: &Literal, expected: &PropType, prop_name: &str) -> Re
|
|||
match lit {
|
||||
Literal::String(v) => {
|
||||
if !allowed.contains(v) {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: property `{}` expects one of [{}], got '{}'",
|
||||
prop_name,
|
||||
allowed.join(", "),
|
||||
|
|
@ -1520,7 +1520,7 @@ fn check_literal_type(lit: &Literal, expected: &PropType, prop_name: &str) -> Re
|
|||
match item {
|
||||
Literal::String(v) if allowed.contains(v) => {}
|
||||
Literal::String(v) => {
|
||||
return Err(CompilerError::Type(format!(
|
||||
return Err(NanoError::Type(format!(
|
||||
"T3: property `{}` expects one of [{}], got '{}'",
|
||||
prop_name,
|
||||
allowed.join(", "),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::fmt;
|
|||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::error::CompilerError;
|
||||
use crate::error::NanoError;
|
||||
use crate::ir::ParamMap;
|
||||
use crate::json_output::{JS_MAX_SAFE_INTEGER_U64, is_js_safe_integer_i64};
|
||||
use crate::query::ast::{Literal, Param, QueryDecl};
|
||||
|
|
@ -17,7 +17,7 @@ pub enum JsonParamMode {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub enum RunInputError {
|
||||
Core(CompilerError),
|
||||
Core(NanoError),
|
||||
Message(String),
|
||||
}
|
||||
|
||||
|
|
@ -45,8 +45,8 @@ impl Error for RunInputError {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<CompilerError> for RunInputError {
|
||||
fn from(value: CompilerError) -> Self {
|
||||
impl From<NanoError> for RunInputError {
|
||||
fn from(value: NanoError) -> Self {
|
||||
Self::Core(value)
|
||||
}
|
||||
}
|
||||
|
|
@ -120,7 +120,7 @@ impl ToParam for i64 {
|
|||
impl ToParam for isize {
|
||||
fn to_param(self) -> crate::error::Result<Literal> {
|
||||
let value = i64::try_from(self).map_err(|_| {
|
||||
CompilerError::Execution(format!(
|
||||
NanoError::Execution(format!(
|
||||
"param value {} exceeds current engine range for numeric literals (max {})",
|
||||
self,
|
||||
i64::MAX
|
||||
|
|
@ -151,7 +151,7 @@ impl ToParam for u32 {
|
|||
impl ToParam for u64 {
|
||||
fn to_param(self) -> crate::error::Result<Literal> {
|
||||
let value = i64::try_from(self).map_err(|_| {
|
||||
CompilerError::Execution(format!(
|
||||
NanoError::Execution(format!(
|
||||
"param value {} exceeds current engine range for numeric literals (max {})",
|
||||
self,
|
||||
i64::MAX
|
||||
|
|
@ -164,7 +164,7 @@ impl ToParam for u64 {
|
|||
impl ToParam for usize {
|
||||
fn to_param(self) -> crate::error::Result<Literal> {
|
||||
let value = i64::try_from(self).map_err(|_| {
|
||||
CompilerError::Execution(format!(
|
||||
NanoError::Execution(format!(
|
||||
"param value {} exceeds current engine range for numeric literals (max {})",
|
||||
self,
|
||||
i64::MAX
|
||||
|
|
@ -177,7 +177,7 @@ impl ToParam for usize {
|
|||
impl ToParam for f32 {
|
||||
fn to_param(self) -> crate::error::Result<Literal> {
|
||||
if !self.is_finite() {
|
||||
return Err(CompilerError::Execution(format!(
|
||||
return Err(NanoError::Execution(format!(
|
||||
"invalid float parameter {}",
|
||||
self
|
||||
)));
|
||||
|
|
@ -189,7 +189,7 @@ impl ToParam for f32 {
|
|||
impl ToParam for f64 {
|
||||
fn to_param(self) -> crate::error::Result<Literal> {
|
||||
if !self.is_finite() {
|
||||
return Err(CompilerError::Execution(format!(
|
||||
return Err(NanoError::Execution(format!(
|
||||
"invalid float parameter {}",
|
||||
self
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use arrow_ipc::writer::StreamWriter;
|
|||
use arrow_schema::{DataType, Field, Schema, SchemaRef};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::{CompilerError, Result};
|
||||
use crate::error::{NanoError, Result};
|
||||
use crate::json_output::{record_batches_to_json_rows, record_batches_to_rust_json_rows};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
|
|
@ -47,7 +47,7 @@ impl QueryResult {
|
|||
}
|
||||
|
||||
arrow_select::concat::concat_batches(&self.schema, &self.batches)
|
||||
.map_err(|err| CompilerError::Execution(err.to_string()))
|
||||
.map_err(|err| NanoError::Execution(err.to_string()))
|
||||
}
|
||||
|
||||
pub fn to_sdk_json(&self) -> serde_json::Value {
|
||||
|
|
@ -60,7 +60,7 @@ impl QueryResult {
|
|||
|
||||
pub fn deserialize<T: DeserializeOwned>(&self) -> Result<T> {
|
||||
serde_json::from_value(self.to_rust_json()).map_err(|err| {
|
||||
CompilerError::Execution(format!("failed to deserialize query result: {}", err))
|
||||
NanoError::Execution(format!("failed to deserialize query result: {}", err))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::types::PropType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -52,11 +50,6 @@ pub struct PropDecl {
|
|||
pub struct Annotation {
|
||||
pub name: String,
|
||||
pub value: Option<String>,
|
||||
/// Keyword arguments, e.g. `model="…"` on `@embed("source", model="…")`.
|
||||
/// Empty is skipped in serialization so existing schemas' IR JSON (and
|
||||
/// hash) stay byte-identical; `BTreeMap` keeps the order deterministic.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub kwargs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// A typed constraint declared in a node or edge body.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use pest::error::InputLocation;
|
|||
use pest_derive::Parser;
|
||||
|
||||
use crate::error::{
|
||||
CompilerError, ParseDiagnostic, Result, SourceSpan, decode_string_literal, render_span,
|
||||
NanoError, ParseDiagnostic, Result, SourceSpan, decode_string_literal, render_span,
|
||||
};
|
||||
use crate::types::{PropType, ScalarType};
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ use super::ast::*;
|
|||
struct SchemaParser;
|
||||
|
||||
pub fn parse_schema(input: &str) -> Result<SchemaFile> {
|
||||
parse_schema_diagnostic(input).map_err(|e| CompilerError::Parse(e.to_string()))
|
||||
parse_schema_diagnostic(input).map_err(|e| NanoError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn parse_schema_diagnostic(input: &str) -> std::result::Result<SchemaFile, ParseDiagnostic> {
|
||||
|
|
@ -27,8 +27,7 @@ pub fn parse_schema_diagnostic(input: &str) -> std::result::Result<SchemaFile, P
|
|||
if pair.as_rule() == Rule::schema_file {
|
||||
for inner in pair.into_inner() {
|
||||
if let Rule::schema_decl = inner.as_rule() {
|
||||
declarations
|
||||
.push(parse_schema_decl(inner).map_err(compiler_error_to_diagnostic)?);
|
||||
declarations.push(parse_schema_decl(inner).map_err(nano_error_to_diagnostic)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -47,13 +46,13 @@ pub fn parse_schema_diagnostic(input: &str) -> std::result::Result<SchemaFile, P
|
|||
let iface_refs: Vec<&InterfaceDecl> = interfaces.iter().collect();
|
||||
for decl in &mut declarations {
|
||||
if let SchemaDecl::Node(node) = decl {
|
||||
resolve_interfaces(node, &iface_refs).map_err(compiler_error_to_diagnostic)?;
|
||||
resolve_interfaces(node, &iface_refs).map_err(nano_error_to_diagnostic)?;
|
||||
}
|
||||
}
|
||||
|
||||
let schema = SchemaFile { declarations };
|
||||
validate_schema_annotations(&schema).map_err(compiler_error_to_diagnostic)?;
|
||||
validate_constraints(&schema).map_err(compiler_error_to_diagnostic)?;
|
||||
validate_schema_annotations(&schema).map_err(nano_error_to_diagnostic)?;
|
||||
validate_constraints(&schema).map_err(nano_error_to_diagnostic)?;
|
||||
Ok(schema)
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +64,7 @@ fn pest_error_to_diagnostic(err: pest::error::Error<Rule>) -> ParseDiagnostic {
|
|||
ParseDiagnostic::new(err.to_string(), span)
|
||||
}
|
||||
|
||||
fn compiler_error_to_diagnostic(err: CompilerError) -> ParseDiagnostic {
|
||||
fn nano_error_to_diagnostic(err: NanoError) -> ParseDiagnostic {
|
||||
ParseDiagnostic::new(err.to_string(), None)
|
||||
}
|
||||
|
||||
|
|
@ -75,7 +74,7 @@ fn parse_schema_decl(pair: pest::iterators::Pair<Rule>) -> Result<SchemaDecl> {
|
|||
Rule::interface_decl => Ok(SchemaDecl::Interface(parse_interface_decl(inner)?)),
|
||||
Rule::node_decl => Ok(SchemaDecl::Node(parse_node_decl(inner)?)),
|
||||
Rule::edge_decl => Ok(SchemaDecl::Edge(parse_edge_decl(inner)?)),
|
||||
_ => Err(CompilerError::Parse(format!(
|
||||
_ => Err(NanoError::Parse(format!(
|
||||
"unexpected rule: {:?}",
|
||||
inner.as_rule()
|
||||
))),
|
||||
|
|
@ -181,20 +180,21 @@ fn parse_cardinality(pair: pest::iterators::Pair<Rule>) -> Result<Cardinality> {
|
|||
let min_str = inner.next().unwrap().as_str();
|
||||
let min = min_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| CompilerError::Parse(format!("invalid cardinality min: {}", min_str)))?;
|
||||
let max =
|
||||
if let Some(max_pair) = inner.next() {
|
||||
let max_str = max_pair.as_str();
|
||||
Some(max_str.parse::<u32>().map_err(|_| {
|
||||
CompilerError::Parse(format!("invalid cardinality max: {}", max_str))
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
.map_err(|_| NanoError::Parse(format!("invalid cardinality min: {}", min_str)))?;
|
||||
let max = if let Some(max_pair) = inner.next() {
|
||||
let max_str = max_pair.as_str();
|
||||
Some(
|
||||
max_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| NanoError::Parse(format!("invalid cardinality max: {}", max_str)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(max_val) = max {
|
||||
if min > max_val {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"cardinality min ({}) exceeds max ({})",
|
||||
min, max_val
|
||||
)));
|
||||
|
|
@ -219,7 +219,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
|
|||
.map(|a| extract_ident_from_constraint_arg(a))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if names.is_empty() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"@key constraint requires at least one property name".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
|
|||
"unique" => {
|
||||
let names = extract_ident_list_from_args(args)?;
|
||||
if names.is_empty() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"@unique constraint requires at least one property name".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -237,7 +237,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
|
|||
"index" => {
|
||||
let names = extract_ident_list_from_args(args)?;
|
||||
if names.is_empty() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"@index constraint requires at least one property name".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -246,7 +246,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
|
|||
"range" => {
|
||||
// @range(prop, min..max)
|
||||
if args.len() < 2 {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"@range requires property name and bounds: @range(prop, min..max)".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -258,7 +258,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
|
|||
"check" => {
|
||||
// @check(prop, "regex")
|
||||
if args.len() < 2 {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"@check requires property name and pattern: @check(prop, \"regex\")"
|
||||
.to_string(),
|
||||
));
|
||||
|
|
@ -267,10 +267,7 @@ fn parse_body_constraint(pair: pest::iterators::Pair<Rule>) -> Result<Constraint
|
|||
let pattern = extract_string_from_constraint_arg(&args[1])?;
|
||||
Ok(Constraint::Check { property, pattern })
|
||||
}
|
||||
other => Err(CompilerError::Parse(format!(
|
||||
"unknown constraint: @{}",
|
||||
other
|
||||
))),
|
||||
other => Err(NanoError::Parse(format!("unknown constraint: @{}", other))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,7 +281,7 @@ fn extract_ident_from_constraint_arg(pair: pest::iterators::Pair<Rule>) -> Resul
|
|||
return Ok(inner.as_str().to_string());
|
||||
}
|
||||
}
|
||||
Err(CompilerError::Parse(
|
||||
Err(NanoError::Parse(
|
||||
"expected property name in constraint".to_string(),
|
||||
))
|
||||
}
|
||||
|
|
@ -312,7 +309,7 @@ fn extract_string_from_constraint_arg(pair: &pest::iterators::Pair<Rule>) -> Res
|
|||
}
|
||||
|
||||
find_string(pair)?
|
||||
.ok_or_else(|| CompilerError::Parse("expected string argument in constraint".to_string()))
|
||||
.ok_or_else(|| NanoError::Parse("expected string argument in constraint".to_string()))
|
||||
}
|
||||
|
||||
fn extract_range_bounds(
|
||||
|
|
@ -330,9 +327,7 @@ fn extract_range_bounds(
|
|||
}
|
||||
}
|
||||
found.ok_or_else(|| {
|
||||
CompilerError::Parse(
|
||||
"expected range bounds (min..max) in @range constraint".to_string(),
|
||||
)
|
||||
NanoError::Parse("expected range bounds (min..max) in @range constraint".to_string())
|
||||
})?
|
||||
};
|
||||
|
||||
|
|
@ -383,7 +378,7 @@ fn parse_constraint_bound(pair: &pest::iterators::Pair<Rule>) -> Result<Constrai
|
|||
}
|
||||
}
|
||||
|
||||
Err(CompilerError::Parse(format!(
|
||||
Err(NanoError::Parse(format!(
|
||||
"invalid constraint bound: {}",
|
||||
text
|
||||
)))
|
||||
|
|
@ -416,7 +411,7 @@ fn resolve_interfaces(node: &mut NodeDecl, interfaces: &[&InterfaceDecl]) -> Res
|
|||
|
||||
for iface_name in &node.implements {
|
||||
let iface = interface_map.get(iface_name.as_str()).ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"node {} implements unknown interface '{}'",
|
||||
node.name, iface_name
|
||||
))
|
||||
|
|
@ -426,7 +421,7 @@ fn resolve_interfaces(node: &mut NodeDecl, interfaces: &[&InterfaceDecl]) -> Res
|
|||
if let Some(existing) = node.properties.iter().find(|p| p.name == iface_prop.name) {
|
||||
// Property exists — verify type compatibility
|
||||
if existing.prop_type != iface_prop.prop_type {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"node {} property '{}' has type {} but interface {} declares it as {}",
|
||||
node.name,
|
||||
iface_prop.name,
|
||||
|
|
@ -477,35 +472,36 @@ fn parse_type_ref(pair: pest::iterators::Pair<Rule>) -> Result<PropType> {
|
|||
let mut inner = pair
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("type reference is missing core type".to_string()))?;
|
||||
.ok_or_else(|| NanoError::Parse("type reference is missing core type".to_string()))?;
|
||||
if inner.as_rule() == Rule::core_type {
|
||||
inner = inner.into_inner().next().ok_or_else(|| {
|
||||
CompilerError::Parse("type reference is missing core type".to_string())
|
||||
})?;
|
||||
inner = inner
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("type reference is missing core type".to_string()))?;
|
||||
}
|
||||
|
||||
match inner.as_rule() {
|
||||
Rule::base_type => {
|
||||
let scalar = ScalarType::from_str_name(inner.as_str())
|
||||
.ok_or_else(|| CompilerError::Parse(format!("unknown type: {}", inner.as_str())))?;
|
||||
.ok_or_else(|| NanoError::Parse(format!("unknown type: {}", inner.as_str())))?;
|
||||
Ok(PropType::scalar(scalar, nullable))
|
||||
}
|
||||
Rule::vector_type => {
|
||||
let dim_text = inner
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| CompilerError::Parse("Vector type missing dimension".to_string()))?
|
||||
.ok_or_else(|| NanoError::Parse("Vector type missing dimension".to_string()))?
|
||||
.as_str();
|
||||
let dim = dim_text
|
||||
.parse::<u32>()
|
||||
.map_err(|e| CompilerError::Parse(format!("invalid Vector dimension: {}", e)))?;
|
||||
.map_err(|e| NanoError::Parse(format!("invalid Vector dimension: {}", e)))?;
|
||||
if dim == 0 {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"Vector dimension must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
if dim > i32::MAX as u32 {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"Vector dimension {} exceeds maximum supported {}",
|
||||
dim,
|
||||
i32::MAX
|
||||
|
|
@ -514,14 +510,15 @@ fn parse_type_ref(pair: pest::iterators::Pair<Rule>) -> Result<PropType> {
|
|||
Ok(PropType::scalar(ScalarType::Vector(dim), nullable))
|
||||
}
|
||||
Rule::list_type => {
|
||||
let element = inner.into_inner().next().ok_or_else(|| {
|
||||
CompilerError::Parse("list type missing element type".to_string())
|
||||
})?;
|
||||
let element = inner
|
||||
.into_inner()
|
||||
.next()
|
||||
.ok_or_else(|| NanoError::Parse("list type missing element type".to_string()))?;
|
||||
let scalar = ScalarType::from_str_name(element.as_str()).ok_or_else(|| {
|
||||
CompilerError::Parse(format!("unknown list element type: {}", element.as_str()))
|
||||
NanoError::Parse(format!("unknown list element type: {}", element.as_str()))
|
||||
})?;
|
||||
if matches!(scalar, ScalarType::Blob) {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"list of Blob is not supported".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -535,7 +532,7 @@ fn parse_type_ref(pair: pest::iterators::Pair<Rule>) -> Result<PropType> {
|
|||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"enum type must include at least one value".to_string(),
|
||||
));
|
||||
}
|
||||
|
|
@ -543,13 +540,13 @@ fn parse_type_ref(pair: pest::iterators::Pair<Rule>) -> Result<PropType> {
|
|||
dedup.sort();
|
||||
dedup.dedup();
|
||||
if dedup.len() != values.len() {
|
||||
return Err(CompilerError::Parse(
|
||||
return Err(NanoError::Parse(
|
||||
"enum type cannot include duplicate values".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(PropType::enum_type(values, nullable))
|
||||
}
|
||||
other => Err(CompilerError::Parse(format!(
|
||||
other => Err(NanoError::Parse(format!(
|
||||
"unexpected type rule: {:?}",
|
||||
other
|
||||
))),
|
||||
|
|
@ -559,32 +556,12 @@ fn parse_type_ref(pair: pest::iterators::Pair<Rule>) -> Result<PropType> {
|
|||
fn parse_annotation(pair: pest::iterators::Pair<Rule>) -> Result<Annotation> {
|
||||
let mut inner = pair.into_inner();
|
||||
let name = inner.next().unwrap().as_str().to_string();
|
||||
let mut value = None;
|
||||
let mut kwargs = std::collections::BTreeMap::new();
|
||||
if let Some(args) = inner.next() {
|
||||
// `annotation_args`: one positional arg followed by zero or more
|
||||
// `key = literal` kwargs (e.g. `@embed("source", model="…")`).
|
||||
for arg in args.into_inner() {
|
||||
match arg.as_rule() {
|
||||
Rule::annotation_arg => {
|
||||
value = Some(decode_string_literal(arg.as_str())?);
|
||||
}
|
||||
Rule::annotation_kwarg => {
|
||||
let mut kw = arg.into_inner();
|
||||
let key = kw.next().unwrap().as_str().to_string();
|
||||
let raw = kw.next().unwrap().as_str();
|
||||
kwargs.insert(key, decode_string_literal(raw)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let value = inner
|
||||
.next()
|
||||
.map(|p| decode_string_literal(p.as_str()))
|
||||
.transpose()?;
|
||||
|
||||
Ok(Annotation {
|
||||
name,
|
||||
value,
|
||||
kwargs,
|
||||
})
|
||||
Ok(Annotation { name, value })
|
||||
}
|
||||
|
||||
fn validate_string_annotation(
|
||||
|
|
@ -598,19 +575,19 @@ fn validate_string_annotation(
|
|||
continue;
|
||||
}
|
||||
if seen {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"{} declares @{} multiple times",
|
||||
target, annotation
|
||||
)));
|
||||
}
|
||||
let value = ann.value.as_deref().ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@{} on {} requires a non-empty value",
|
||||
annotation, target
|
||||
))
|
||||
})?;
|
||||
if value.trim().is_empty() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} on {} requires a non-empty value",
|
||||
annotation, target
|
||||
)));
|
||||
|
|
@ -634,7 +611,7 @@ fn validate_schema_annotations(schema: &SchemaFile) -> Result<()> {
|
|||
|| ann.name == "index"
|
||||
|| ann.name == "embed"
|
||||
{
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} is only supported on node properties or as body constraint (node {})",
|
||||
ann.name, node.name
|
||||
)));
|
||||
|
|
@ -663,7 +640,7 @@ fn validate_schema_annotations(schema: &SchemaFile) -> Result<()> {
|
|||
|| ann.name == "index"
|
||||
|| ann.name == "embed"
|
||||
{
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} is not supported on edges (edge {})",
|
||||
ann.name, edge.name
|
||||
)));
|
||||
|
|
@ -717,13 +694,13 @@ fn validate_property_annotations(
|
|||
|| ann.name == "index"
|
||||
|| ann.name == "embed")
|
||||
{
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} is not supported on list property {}.{}",
|
||||
ann.name, type_name, prop.name
|
||||
)));
|
||||
}
|
||||
if is_vector && (ann.name == "key" || ann.name == "unique") {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} is not supported on vector property {}.{}",
|
||||
ann.name, type_name, prop.name
|
||||
)));
|
||||
|
|
@ -734,13 +711,13 @@ fn validate_property_annotations(
|
|||
|| ann.name == "index"
|
||||
|| ann.name == "embed")
|
||||
{
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} is not supported on blob property {}.{}",
|
||||
ann.name, type_name, prop.name
|
||||
)));
|
||||
}
|
||||
if ann.name == "instruction" {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@instruction is only supported on node and edge types (property {}.{})",
|
||||
type_name, prop.name
|
||||
)));
|
||||
|
|
@ -748,7 +725,7 @@ fn validate_property_annotations(
|
|||
|
||||
// Edge-specific restrictions
|
||||
if is_edge && (ann.name == "key" || ann.name == "embed") {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@{} is not supported on edge properties (edge {}.{})",
|
||||
ann.name, type_name, prop.name
|
||||
)));
|
||||
|
|
@ -758,13 +735,13 @@ fn validate_property_annotations(
|
|||
match ann.name.as_str() {
|
||||
"key" => {
|
||||
if ann.value.is_some() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@key on {}.{} does not accept a value",
|
||||
type_name, prop.name
|
||||
)));
|
||||
}
|
||||
if key_seen {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"property {}.{} declares @key multiple times",
|
||||
type_name, prop.name
|
||||
)));
|
||||
|
|
@ -773,13 +750,13 @@ fn validate_property_annotations(
|
|||
}
|
||||
"unique" => {
|
||||
if ann.value.is_some() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@unique on {}.{} does not accept a value",
|
||||
type_name, prop.name
|
||||
)));
|
||||
}
|
||||
if unique_seen {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"property {}.{} declares @unique multiple times",
|
||||
type_name, prop.name
|
||||
)));
|
||||
|
|
@ -788,13 +765,13 @@ fn validate_property_annotations(
|
|||
}
|
||||
"index" => {
|
||||
if ann.value.is_some() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@index on {}.{} does not accept a value",
|
||||
type_name, prop.name
|
||||
)));
|
||||
}
|
||||
if index_seen {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"property {}.{} declares @index multiple times",
|
||||
type_name, prop.name
|
||||
)));
|
||||
|
|
@ -803,7 +780,7 @@ fn validate_property_annotations(
|
|||
}
|
||||
"embed" => {
|
||||
if embed_seen {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"property {}.{} declares @embed multiple times",
|
||||
type_name, prop.name
|
||||
)));
|
||||
|
|
@ -811,20 +788,20 @@ fn validate_property_annotations(
|
|||
embed_seen = true;
|
||||
|
||||
if !is_vector {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@embed is only supported on vector properties ({}.{})",
|
||||
type_name, prop.name
|
||||
)));
|
||||
}
|
||||
|
||||
let source_prop = ann.value.as_deref().ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@embed on {}.{} requires a source property name",
|
||||
type_name, prop.name
|
||||
))
|
||||
})?;
|
||||
if source_prop.trim().is_empty() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@embed on {}.{} requires a non-empty source property name",
|
||||
type_name, prop.name
|
||||
)));
|
||||
|
|
@ -834,29 +811,18 @@ fn validate_property_annotations(
|
|||
.iter()
|
||||
.find(|p| p.name == source_prop)
|
||||
.ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@embed on {}.{} references unknown source property {}",
|
||||
type_name, prop.name, source_prop
|
||||
))
|
||||
})?;
|
||||
if source_decl.prop_type.list || source_decl.prop_type.scalar != ScalarType::String
|
||||
{
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@embed source property {}.{} must be String",
|
||||
type_name, source_prop
|
||||
)));
|
||||
}
|
||||
|
||||
// `model` is the only supported kwarg; reject the rest loudly so
|
||||
// a typo can't be silently ignored (it would never validate).
|
||||
for key in ann.kwargs.keys() {
|
||||
if key != "model" {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
"@embed on {}.{} has unknown argument '{}=' (only 'model' is supported)",
|
||||
type_name, prop.name, key
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -896,45 +862,45 @@ fn validate_type_constraints(
|
|||
match constraint {
|
||||
Constraint::Key(cols) => {
|
||||
if is_edge {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@key constraint is not supported on edges (edge {})",
|
||||
type_name
|
||||
)));
|
||||
}
|
||||
key_count += 1;
|
||||
if key_count > 1 {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"node type {} has multiple @key constraints; only one is supported",
|
||||
type_name
|
||||
)));
|
||||
}
|
||||
for col in cols {
|
||||
let prop = prop_names.get(col.as_str()).ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@key on {} references unknown property '{}'",
|
||||
type_name, col
|
||||
))
|
||||
})?;
|
||||
if prop.prop_type.nullable {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@key property {}.{} cannot be nullable",
|
||||
type_name, col
|
||||
)));
|
||||
}
|
||||
if prop.prop_type.list {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@key is not supported on list property {}.{}",
|
||||
type_name, col
|
||||
)));
|
||||
}
|
||||
if matches!(prop.prop_type.scalar, ScalarType::Vector(_)) {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@key is not supported on vector property {}.{}",
|
||||
type_name, col
|
||||
)));
|
||||
}
|
||||
if matches!(prop.prop_type.scalar, ScalarType::Blob) {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@key is not supported on blob property {}.{}",
|
||||
type_name, col
|
||||
)));
|
||||
|
|
@ -948,7 +914,7 @@ fn validate_type_constraints(
|
|||
continue;
|
||||
}
|
||||
if !prop_names.contains_key(col.as_str()) {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@unique on {} references unknown property '{}'",
|
||||
type_name, col
|
||||
)));
|
||||
|
|
@ -961,13 +927,13 @@ fn validate_type_constraints(
|
|||
continue;
|
||||
}
|
||||
let prop = prop_names.get(col.as_str()).ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@index on {} references unknown property '{}'",
|
||||
type_name, col
|
||||
))
|
||||
})?;
|
||||
if matches!(prop.prop_type.scalar, ScalarType::Blob) {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@index is not supported on blob property {}.{}",
|
||||
type_name, col
|
||||
)));
|
||||
|
|
@ -976,19 +942,19 @@ fn validate_type_constraints(
|
|||
}
|
||||
Constraint::Range { property, .. } => {
|
||||
if is_edge {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@range constraint is not supported on edges (edge {})",
|
||||
type_name
|
||||
)));
|
||||
}
|
||||
let prop = prop_names.get(property.as_str()).ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@range on {} references unknown property '{}'",
|
||||
type_name, property
|
||||
))
|
||||
})?;
|
||||
if !prop.prop_type.scalar.is_numeric() {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@range on {}.{} requires a numeric type, got {}",
|
||||
type_name,
|
||||
property,
|
||||
|
|
@ -998,19 +964,19 @@ fn validate_type_constraints(
|
|||
}
|
||||
Constraint::Check { property, .. } => {
|
||||
if is_edge {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@check constraint is not supported on edges (edge {})",
|
||||
type_name
|
||||
)));
|
||||
}
|
||||
let prop = prop_names.get(property.as_str()).ok_or_else(|| {
|
||||
CompilerError::Parse(format!(
|
||||
NanoError::Parse(format!(
|
||||
"@check on {} references unknown property '{}'",
|
||||
type_name, property
|
||||
))
|
||||
})?;
|
||||
if prop.prop_type.scalar != ScalarType::String {
|
||||
return Err(CompilerError::Parse(format!(
|
||||
return Err(NanoError::Parse(format!(
|
||||
"@check on {}.{} requires String type, got {}",
|
||||
type_name,
|
||||
property,
|
||||
|
|
|
|||
|
|
@ -508,66 +508,6 @@ embedding: Vector(3) @embed(title)
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embed_annotation_with_model_kwarg() {
|
||||
let input = r#"
|
||||
node Doc {
|
||||
title: String
|
||||
embedding: Vector(3) @embed("title", model="openai/text-embedding-3-large")
|
||||
}
|
||||
"#;
|
||||
let schema = parse_schema(input).unwrap();
|
||||
match &schema.declarations[0] {
|
||||
SchemaDecl::Node(n) => {
|
||||
let ann = &n.properties[1].annotations[0];
|
||||
assert_eq!(ann.name, "embed");
|
||||
assert_eq!(ann.value.as_deref(), Some("title"));
|
||||
assert_eq!(
|
||||
ann.kwargs.get("model").map(String::as_str),
|
||||
Some("openai/text-embedding-3-large")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Node"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embed_annotation_without_model_has_empty_kwargs() {
|
||||
let input = r#"
|
||||
node Doc {
|
||||
title: String
|
||||
embedding: Vector(3) @embed("title")
|
||||
}
|
||||
"#;
|
||||
let schema = parse_schema(input).unwrap();
|
||||
match &schema.declarations[0] {
|
||||
SchemaDecl::Node(n) => {
|
||||
let ann = &n.properties[1].annotations[0];
|
||||
assert!(ann.kwargs.is_empty());
|
||||
// Empty kwargs must NOT serialize, so existing schemas' IR JSON (and
|
||||
// thus the schema hash) stay byte-identical after this field is added.
|
||||
let json = serde_json::to_string(ann).unwrap();
|
||||
assert!(!json.contains("kwargs"), "unexpected kwargs in {json}");
|
||||
}
|
||||
_ => panic!("expected Node"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embed_annotation_rejects_unknown_kwarg() {
|
||||
let input = r#"
|
||||
node Doc {
|
||||
title: String
|
||||
embedding: Vector(3) @embed("title", provider="openai")
|
||||
}
|
||||
"#;
|
||||
let err = parse_schema(input).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("only 'model' is supported"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_edge_no_body() {
|
||||
let input = "edge WorksAt: Person -> Company\n";
|
||||
|
|
|
|||
|
|
@ -42,10 +42,8 @@ enum_value = @{ (ASCII_ALPHANUMERIC | "_" | "-")+ }
|
|||
base_type = { "String" | "Blob" | "Bool" | "I32" | "I64" | "U32" | "U64" | "F32" | "F64" | "DateTime" | "Date" }
|
||||
|
||||
// Annotation rule excludes constraint keywords followed by "(" — those are body_constraints
|
||||
annotation = { "@" ~ !(constraint_name ~ "(") ~ ident ~ ("(" ~ annotation_args ~ ")")? }
|
||||
annotation_args = { annotation_arg ~ ("," ~ annotation_kwarg)* }
|
||||
annotation = { "@" ~ !(constraint_name ~ "(") ~ ident ~ ("(" ~ annotation_arg ~ ")")? }
|
||||
annotation_arg = { literal | ident }
|
||||
annotation_kwarg = { ident ~ "=" ~ literal }
|
||||
|
||||
literal = { string_lit | float_lit | integer | bool_lit }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "omnigraph-policy"
|
||||
version = "0.7.2"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "Policy / authorization layer for Omnigraph — Cedar-backed PolicyEngine, PolicyChecker trait, ResourceScope enum."
|
||||
license = "MIT"
|
||||
|
|
|
|||
|
|
@ -56,21 +56,6 @@ pub enum PolicyAction {
|
|||
/// from v0.6.0; operators add and remove graphs by editing
|
||||
/// `omnigraph.yaml` and restarting.
|
||||
GraphList,
|
||||
/// Gates invoking a server-side stored query by name. Per-graph and
|
||||
/// **graph-scoped** (no branch dimension, like `Admin`): the per-branch
|
||||
/// access of the query body is enforced by the inner `Read`/`Change`
|
||||
/// gate, so branch-scoping this outer gate would be redundant (and was
|
||||
/// wrong for snapshot reads). A rule that sets `branch_scope` on
|
||||
/// `invoke_query` is rejected by `validate()`. In this release it is
|
||||
/// **coarse**: an `invoke_query` allow rule permits *any* stored query
|
||||
/// on the graph (no per-query dimension yet); a future, additive
|
||||
/// refinement adds an optional query-name scope.
|
||||
///
|
||||
/// This gate sits at the HTTP boundary. The engine `_as` writers still
|
||||
/// enforce `Read`/`Change` per the query body, so a stored *mutation*
|
||||
/// is double-gated: `invoke_query` to reach the tool, plus `change` for
|
||||
/// the write itself.
|
||||
InvokeQuery,
|
||||
}
|
||||
|
||||
impl PolicyAction {
|
||||
|
|
@ -85,7 +70,6 @@ impl PolicyAction {
|
|||
Self::BranchMerge => "branch_merge",
|
||||
Self::Admin => "admin",
|
||||
Self::GraphList => "graph_list",
|
||||
Self::InvokeQuery => "invoke_query",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,8 +99,7 @@ impl PolicyAction {
|
|||
| Self::BranchCreate
|
||||
| Self::BranchDelete
|
||||
| Self::BranchMerge
|
||||
| Self::Admin
|
||||
| Self::InvokeQuery => PolicyResourceKind::Graph,
|
||||
| Self::Admin => PolicyResourceKind::Graph,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -172,7 +155,6 @@ impl FromStr for PolicyAction {
|
|||
"branch_merge" => Ok(Self::BranchMerge),
|
||||
"admin" => Ok(Self::Admin),
|
||||
"graph_list" => Ok(Self::GraphList),
|
||||
"invoke_query" => Ok(Self::InvokeQuery),
|
||||
other => bail!("unknown policy action '{other}'"),
|
||||
}
|
||||
}
|
||||
|
|
@ -277,14 +259,7 @@ pub struct PolicyEngine {
|
|||
|
||||
impl PolicyConfig {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
Self::from_source(&fs::read_to_string(path)?)
|
||||
}
|
||||
|
||||
/// Parse + validate a policy from YAML source. The from-content twin of
|
||||
/// `load` for callers whose policies don't live on the local filesystem
|
||||
/// (e.g. a cluster catalog on object storage).
|
||||
pub fn from_source(source: &str) -> Result<Self> {
|
||||
let config: Self = serde_yaml::from_str(source)?;
|
||||
let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
|
@ -472,26 +447,13 @@ impl PolicyEngine {
|
|||
PolicyCompiler::compile(&config, graph_id)
|
||||
}
|
||||
|
||||
/// `load_graph` from YAML content instead of a file path — for policies
|
||||
/// that live in a non-filesystem catalog (cluster object storage).
|
||||
pub fn load_graph_from_source(source: &str, graph_id: &str) -> Result<Self> {
|
||||
let config = PolicyConfig::from_source(source)?;
|
||||
validate_kind_alignment(&config, PolicyEngineKind::Graph)?;
|
||||
PolicyCompiler::compile(&config, graph_id)
|
||||
}
|
||||
|
||||
/// Load a server-level policy file. Rejects rules whose actions
|
||||
/// are per-graph (e.g. `read`, `change`) — those belong in a
|
||||
/// per-graph policy file, not the server one. Takes no `graph_id`:
|
||||
/// server-scoped actions resolve against the singleton
|
||||
/// `Omnigraph::Server::"root"` entity, never a Graph.
|
||||
pub fn load_server(path: &Path) -> Result<Self> {
|
||||
Self::load_server_from_source(&fs::read_to_string(path)?)
|
||||
}
|
||||
|
||||
/// `load_server` from YAML content instead of a file path.
|
||||
pub fn load_server_from_source(source: &str) -> Result<Self> {
|
||||
let config = PolicyConfig::from_source(source)?;
|
||||
let config = PolicyConfig::load(path)?;
|
||||
validate_kind_alignment(&config, PolicyEngineKind::Server)?;
|
||||
// The Graph entity created by the compiler is never referenced
|
||||
// by a server-scoped rule, so the label below is purely a
|
||||
|
|
@ -844,7 +806,6 @@ namespace Omnigraph {
|
|||
action "branch_delete" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
|
||||
action "branch_merge" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
|
||||
action "admin" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
|
||||
action "invoke_query" appliesTo { principal: Actor, resource: Graph, context: RequestContext };
|
||||
|
||||
action "graph_list" appliesTo { principal: Actor, resource: Server, context: RequestContext };
|
||||
}
|
||||
|
|
@ -1022,42 +983,6 @@ impl PolicyChecker for PolicyEngine {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn from_source_twins_match_path_loaders() {
|
||||
let yaml = r#"
|
||||
version: 1
|
||||
groups:
|
||||
readers: ["act-r"]
|
||||
protected_branches: [main]
|
||||
rules:
|
||||
- id: r1
|
||||
allow:
|
||||
actors: { group: readers }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
"#;
|
||||
let config = PolicyConfig::from_source(yaml).unwrap();
|
||||
assert_eq!(config.version, 1);
|
||||
let engine = PolicyEngine::load_graph_from_source(yaml, "g1").unwrap();
|
||||
drop(engine);
|
||||
|
||||
let server_yaml = r#"
|
||||
version: 1
|
||||
kind: server
|
||||
groups:
|
||||
admins: ["act-a"]
|
||||
rules:
|
||||
- id: s1
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [graph_list]
|
||||
"#;
|
||||
PolicyEngine::load_server_from_source(server_yaml).unwrap();
|
||||
// Kind misalignment stays loud through the from-source path.
|
||||
assert!(PolicyEngine::load_graph_from_source(server_yaml, "g1").is_err());
|
||||
assert!(PolicyEngine::load_server_from_source(yaml).is_err());
|
||||
}
|
||||
use super::{
|
||||
PolicyAction, PolicyCompiler, PolicyConfig, PolicyEngine, PolicyExpectation, PolicyRequest,
|
||||
PolicyTestCase, PolicyTestConfig,
|
||||
|
|
@ -1339,80 +1264,6 @@ rules:
|
|||
assert!(!deny.allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_query_authorizes_per_graph() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-alice]
|
||||
others: [act-bruno]
|
||||
rules:
|
||||
- id: team-invoke-queries
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [invoke_query]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
|
||||
|
||||
let allow = engine
|
||||
.authorize(
|
||||
"act-alice",
|
||||
&PolicyRequest {
|
||||
action: PolicyAction::InvokeQuery,
|
||||
branch: None,
|
||||
target_branch: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(allow.allowed);
|
||||
assert_eq!(
|
||||
allow.matched_rule_id.as_deref(),
|
||||
Some("team-invoke-queries")
|
||||
);
|
||||
|
||||
// Actor outside the group → deny.
|
||||
let deny = engine
|
||||
.authorize(
|
||||
"act-bruno",
|
||||
&PolicyRequest {
|
||||
action: PolicyAction::InvokeQuery,
|
||||
branch: None,
|
||||
target_branch: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!deny.allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_query_rejects_branch_scope() {
|
||||
// invoke_query is graph-scoped (like admin) — per-branch access is
|
||||
// enforced by the inner read/change gate — so a rule that puts a
|
||||
// `branch_scope` qualifier on it is rejected at validate().
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-alice]
|
||||
rules:
|
||||
- id: team-invoke-any-branch
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [invoke_query]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = policy.validate().unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("branch_scope") && err.contains("invoke_query"),
|
||||
"branch_scope on invoke_query must be rejected: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_scoped_rule_cannot_use_branch_scope() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "omnigraph-server"
|
||||
version = "0.7.2"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "HTTP server for the Omnigraph graph database."
|
||||
license = "MIT"
|
||||
|
|
@ -19,11 +19,9 @@ default = []
|
|||
aws = ["dep:aws-config", "dep:aws-sdk-secretsmanager"]
|
||||
|
||||
[dependencies]
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.7.2" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.2" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.7.2" }
|
||||
omnigraph-api-types = { path = "../omnigraph-api-types", version = "0.7.2" }
|
||||
omnigraph-cluster = { path = "../omnigraph-cluster", version = "0.7.2" }
|
||||
omnigraph = { package = "omnigraph-engine", path = "../omnigraph", version = "0.6.0" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.6.0" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.6.0" }
|
||||
axum = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
color-eyre = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
//! Server-level concurrent HTTP benchmark for MR-686 (PR 0 baseline).
|
||||
//!
|
||||
//! Drives concurrent `/change` requests against an in-process Omnigraph HTTP
|
||||
//! server. Originally written to measure the global `Arc<RwLock<Omnigraph>>`
|
||||
//! lock penalty as an MR-686 baseline; that lock has since been removed
|
||||
//! (engine write APIs are `&self`, the server holds a lockless
|
||||
//! `Arc<Omnigraph>`), so this now measures the concurrent write path itself
|
||||
//! (per-`(table, branch)` queue contention + Lance I/O).
|
||||
//! server. Measures the global `Arc<RwLock<Omnigraph>>` lock penalty on
|
||||
//! current `main` so PR 1 + PR 2 can be evaluated against a real baseline.
|
||||
//!
|
||||
//! Driving the HTTP server is still the right level: an engine-level bench on
|
||||
//! a single handle measures Lance contention, not the server's request-path
|
||||
//! concurrency.
|
||||
//! Per the MR-686 plan: this is the load-bearing bench. `Omnigraph::mutate_as`
|
||||
//! is `&mut self`, so an engine-level concurrent bench either serializes on the
|
||||
//! borrow checker (measures nothing) or drives multiple handles (measures Lance
|
||||
//! contention, not the server bottleneck). Driving the HTTP server is the only
|
||||
//! way to measure the actual `RwLock<Omnigraph>` contention this work removes.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```sh
|
||||
|
|
|
|||
|
|
@ -1,24 +1,536 @@
|
|||
//! HTTP wire DTOs. The types and their engine-result -> DTO mappings live
|
||||
//! in the shared `omnigraph-api-types` crate (RFC-009 Phase 2) so the CLI
|
||||
//! and server share one definition; re-exported here so every
|
||||
//! `omnigraph_server::api::*` path (handlers, the OpenApi schema list,
|
||||
//! CLI imports) keeps resolving unchanged. Only `query_catalog_entry`
|
||||
//! stays — it maps the server's runtime `StoredQuery` (not a wire type)
|
||||
//! into the shared `QueryCatalogEntry` DTO.
|
||||
use omnigraph::db::{GraphCommit, MergeOutcome, ReadTarget, SchemaApplyResult, Snapshot};
|
||||
use omnigraph::error::{MergeConflict, MergeConflictKind};
|
||||
use omnigraph::loader::{IngestResult, LoadMode};
|
||||
use omnigraph_compiler::SchemaMigrationStep;
|
||||
use omnigraph_compiler::result::QueryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
pub use omnigraph_api_types::*;
|
||||
/// Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.
|
||||
#[derive(ToSchema)]
|
||||
#[schema(as = LoadMode)]
|
||||
#[allow(dead_code)]
|
||||
enum LoadModeSchema {
|
||||
/// Overwrite existing data.
|
||||
#[schema(rename = "overwrite")]
|
||||
Overwrite,
|
||||
/// Append to existing data.
|
||||
#[schema(rename = "append")]
|
||||
Append,
|
||||
/// Merge by id key (upsert).
|
||||
#[schema(rename = "merge")]
|
||||
Merge,
|
||||
}
|
||||
|
||||
use crate::queries::StoredQuery;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SnapshotTableOutput {
|
||||
pub table_key: String,
|
||||
pub table_path: String,
|
||||
pub table_version: u64,
|
||||
pub table_branch: Option<String>,
|
||||
pub row_count: u64,
|
||||
}
|
||||
|
||||
/// Project a loaded stored query into its catalog entry (typed params,
|
||||
/// MCP tool name, read/mutate flag, description/instruction).
|
||||
pub fn query_catalog_entry(query: &StoredQuery) -> QueryCatalogEntry {
|
||||
QueryCatalogEntry {
|
||||
name: query.name.clone(),
|
||||
tool_name: query.effective_tool_name().to_string(),
|
||||
description: query.decl.description.clone(),
|
||||
instruction: query.decl.instruction.clone(),
|
||||
mutation: query.is_mutation(),
|
||||
params: query.decl.params.iter().map(param_descriptor).collect(),
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SnapshotOutput {
|
||||
pub branch: String,
|
||||
pub manifest_version: u64,
|
||||
pub tables: Vec<SnapshotTableOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchCreateRequest {
|
||||
/// Parent branch to fork from. Defaults to `main`.
|
||||
pub from: Option<String>,
|
||||
/// Name of the new branch. Must not already exist.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchCreateOutput {
|
||||
pub uri: String,
|
||||
pub from: String,
|
||||
pub name: String,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchListOutput {
|
||||
pub branches: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchDeleteOutput {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchMergeRequest {
|
||||
/// Source branch whose commits will be merged.
|
||||
pub source: String,
|
||||
/// Target branch that will receive the merge. Defaults to `main`.
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BranchMergeOutcome {
|
||||
AlreadyUpToDate,
|
||||
FastForward,
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl From<MergeOutcome> for BranchMergeOutcome {
|
||||
fn from(value: MergeOutcome) -> Self {
|
||||
match value {
|
||||
MergeOutcome::AlreadyUpToDate => Self::AlreadyUpToDate,
|
||||
MergeOutcome::FastForward => Self::FastForward,
|
||||
MergeOutcome::Merged => Self::Merged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BranchMergeOutcome {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AlreadyUpToDate => "already_up_to_date",
|
||||
Self::FastForward => "fast_forward",
|
||||
Self::Merged => "merged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BranchMergeOutput {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub outcome: BranchMergeOutcome,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MergeConflictKindOutput {
|
||||
DivergentInsert,
|
||||
DivergentUpdate,
|
||||
DeleteVsUpdate,
|
||||
OrphanEdge,
|
||||
UniqueViolation,
|
||||
CardinalityViolation,
|
||||
ValueConstraintViolation,
|
||||
}
|
||||
|
||||
impl MergeConflictKindOutput {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::DivergentInsert => "divergent_insert",
|
||||
Self::DivergentUpdate => "divergent_update",
|
||||
Self::DeleteVsUpdate => "delete_vs_update",
|
||||
Self::OrphanEdge => "orphan_edge",
|
||||
Self::UniqueViolation => "unique_violation",
|
||||
Self::CardinalityViolation => "cardinality_violation",
|
||||
Self::ValueConstraintViolation => "value_constraint_violation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeConflictKind> for MergeConflictKindOutput {
|
||||
fn from(value: MergeConflictKind) -> Self {
|
||||
match value {
|
||||
MergeConflictKind::DivergentInsert => Self::DivergentInsert,
|
||||
MergeConflictKind::DivergentUpdate => Self::DivergentUpdate,
|
||||
MergeConflictKind::DeleteVsUpdate => Self::DeleteVsUpdate,
|
||||
MergeConflictKind::OrphanEdge => Self::OrphanEdge,
|
||||
MergeConflictKind::UniqueViolation => Self::UniqueViolation,
|
||||
MergeConflictKind::CardinalityViolation => Self::CardinalityViolation,
|
||||
MergeConflictKind::ValueConstraintViolation => Self::ValueConstraintViolation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MergeConflictOutput {
|
||||
pub table_key: String,
|
||||
pub row_id: Option<String>,
|
||||
pub kind: MergeConflictKindOutput,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<&MergeConflict> for MergeConflictOutput {
|
||||
fn from(value: &MergeConflict) -> Self {
|
||||
Self {
|
||||
table_key: value.table_key.clone(),
|
||||
row_id: value.row_id.clone(),
|
||||
kind: value.kind.into(),
|
||||
message: value.message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReadTargetOutput {
|
||||
pub branch: Option<String>,
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReadOutput {
|
||||
pub query_name: String,
|
||||
pub target: ReadTargetOutput,
|
||||
pub row_count: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ChangeOutput {
|
||||
pub branch: String,
|
||||
pub query_name: String,
|
||||
pub affected_nodes: usize,
|
||||
pub affected_edges: usize,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IngestTableOutput {
|
||||
pub table_key: String,
|
||||
pub rows_loaded: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IngestOutput {
|
||||
pub uri: String,
|
||||
pub branch: String,
|
||||
pub base_branch: String,
|
||||
pub branch_created: bool,
|
||||
#[schema(value_type = LoadModeSchema)]
|
||||
pub mode: LoadMode,
|
||||
pub tables: Vec<IngestTableOutput>,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CommitOutput {
|
||||
pub graph_commit_id: String,
|
||||
pub manifest_branch: Option<String>,
|
||||
pub manifest_version: u64,
|
||||
pub parent_commit_id: Option<String>,
|
||||
pub merged_parent_commit_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
/// Commit creation time as Unix epoch microseconds.
|
||||
#[schema(example = 1714000000000000i64)]
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CommitListOutput {
|
||||
pub commits: Vec<CommitOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ReadRequest {
|
||||
/// GQ query source. May declare one or more named queries; pick one with
|
||||
/// `query_name` if there is more than one.
|
||||
#[schema(
|
||||
example = "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}"
|
||||
)]
|
||||
pub query_source: String,
|
||||
/// Name of the query to run when `query_source` declares multiple. Optional
|
||||
/// when only one query is declared.
|
||||
pub query_name: Option<String>,
|
||||
/// JSON object whose keys match the query's declared parameters.
|
||||
pub params: Option<Value>,
|
||||
/// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Snapshot id to read from. Mutually exclusive with `branch`.
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
/// Inline read-query request for `POST /query`.
|
||||
///
|
||||
/// Friendlier-named alternative to [`ReadRequest`] for ad-hoc reads and
|
||||
/// AI-agent integration. Mutations are rejected with 400 — use `POST
|
||||
/// /mutate` (or its deprecated alias `POST /change`) for write queries.
|
||||
/// Field names are deliberately short (`query`, `name`) to match the GQ
|
||||
/// keyword and the CLI `-e` flag.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct 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.
|
||||
#[schema(example = "query get_person($name: String) {\n match {\n $p: Person { name: $name }\n }\n return { $p.name, $p.age }\n}")]
|
||||
pub query: String,
|
||||
/// Name of the query to run when `query` declares multiple. Optional when
|
||||
/// only one query is declared.
|
||||
pub name: Option<String>,
|
||||
/// JSON object whose keys match the query's declared parameters.
|
||||
pub params: Option<Value>,
|
||||
/// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Snapshot id to read from. Mutually exclusive with `branch`.
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ChangeRequest {
|
||||
/// GQ mutation source containing `insert`, `update`, or `delete` statements.
|
||||
/// May declare multiple named mutations; pick one with `name`.
|
||||
///
|
||||
/// Accepts the legacy field name `query_source` as a deserialization alias.
|
||||
#[schema(
|
||||
example = "query insert_person($name: String, $age: I32) {\n insert Person { name: $name, age: $age }\n}"
|
||||
)]
|
||||
#[serde(alias = "query_source")]
|
||||
pub query: String,
|
||||
/// Name of the mutation to run when `query` declares multiple.
|
||||
///
|
||||
/// Accepts the legacy field name `query_name` as a deserialization alias.
|
||||
#[serde(default, alias = "query_name")]
|
||||
pub name: Option<String>,
|
||||
/// JSON object whose keys match the mutation's declared parameters.
|
||||
#[serde(default)]
|
||||
pub params: Option<Value>,
|
||||
/// Target branch. Defaults to `main`.
|
||||
#[serde(default)]
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SchemaApplyRequest {
|
||||
/// Project schema in `.pg` source form. The diff against the current
|
||||
/// schema produces the migration steps that will be applied.
|
||||
#[schema(
|
||||
example = "node Person {\n name: String @key\n age: I32?\n}\n\nedge Knows: Person -> Person"
|
||||
)]
|
||||
pub schema_source: String,
|
||||
/// When true, promote every `DropMode::Soft` step in the plan to
|
||||
/// `DropMode::Hard`, making the prior column data unreachable
|
||||
/// after the apply. Matches the CLI's `--allow-data-loss` flag.
|
||||
/// Defaults to `false` (drops remain reversible via time travel).
|
||||
#[serde(default)]
|
||||
pub allow_data_loss: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SchemaApplyOutput {
|
||||
pub uri: String,
|
||||
pub supported: bool,
|
||||
pub applied: bool,
|
||||
pub step_count: usize,
|
||||
pub manifest_version: u64,
|
||||
#[schema(value_type = Vec<Value>)]
|
||||
pub steps: Vec<SchemaMigrationStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SchemaOutput {
|
||||
pub schema_source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct IngestRequest {
|
||||
/// Target branch. Created from `from` if it does not yet exist. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Parent branch used to create `branch` if it does not exist. Defaults to `main`.
|
||||
pub from: Option<String>,
|
||||
/// How existing rows are handled. Defaults to `merge`.
|
||||
#[schema(value_type = Option<LoadModeSchema>)]
|
||||
pub mode: Option<LoadMode>,
|
||||
/// NDJSON payload: one record per line, each shaped
|
||||
/// `{"type": "<TypeName>", "data": {...}}`.
|
||||
#[schema(
|
||||
example = "{\"type\": \"Person\", \"data\": {\"name\": \"Alice\", \"age\": 30}}\n{\"type\": \"Person\", \"data\": {\"name\": \"Bob\", \"age\": 25}}"
|
||||
)]
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ExportRequest {
|
||||
/// Branch to export. Defaults to `main`.
|
||||
pub branch: Option<String>,
|
||||
/// Restrict the export to these node/edge type names. Empty exports all types.
|
||||
#[serde(default)]
|
||||
pub type_names: Vec<String>,
|
||||
/// Restrict the export to these table keys. Empty exports all tables.
|
||||
#[serde(default)]
|
||||
pub table_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||
pub struct SnapshotQuery {
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||||
pub struct CommitListQuery {
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthOutput {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
/// 405 Method Not Allowed — the route exists but the active server
|
||||
/// mode doesn't serve this method (e.g. `GET /graphs` in single-graph
|
||||
/// mode). Distinct from 404 so clients can tell "wrong context" from
|
||||
/// "no such resource."
|
||||
MethodNotAllowed,
|
||||
Conflict,
|
||||
/// 429 Too Many Requests — per-actor admission cap exceeded.
|
||||
/// Clients should respect the `Retry-After` header.
|
||||
TooManyRequests,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ManifestConflictOutput {
|
||||
pub table_key: String,
|
||||
pub expected: u64,
|
||||
pub actual: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ErrorOutput {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<ErrorCode>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub merge_conflicts: Vec<MergeConflictOutput>,
|
||||
/// Set when the conflict is a publisher CAS rejection
|
||||
/// (`ManifestConflictDetails::ExpectedVersionMismatch`). The caller's
|
||||
/// pre-write view of `table_key` was at version `expected` but the
|
||||
/// manifest is now at `actual`. Refresh and retry.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_conflict: Option<ManifestConflictOutput>,
|
||||
}
|
||||
|
||||
pub fn snapshot_payload(branch: &str, snapshot: &Snapshot) -> SnapshotOutput {
|
||||
let mut entries: Vec<_> = snapshot.entries().cloned().collect();
|
||||
entries.sort_by(|a, b| a.table_key.cmp(&b.table_key));
|
||||
let tables = entries
|
||||
.iter()
|
||||
.map(|entry| SnapshotTableOutput {
|
||||
table_key: entry.table_key.clone(),
|
||||
table_path: entry.table_path.clone(),
|
||||
table_version: entry.table_version,
|
||||
table_branch: entry.table_branch.clone(),
|
||||
row_count: entry.row_count,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
SnapshotOutput {
|
||||
branch: branch.to_string(),
|
||||
manifest_version: snapshot.version(),
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema_apply_output(uri: &str, result: SchemaApplyResult) -> SchemaApplyOutput {
|
||||
SchemaApplyOutput {
|
||||
uri: uri.to_string(),
|
||||
supported: result.supported,
|
||||
applied: result.applied,
|
||||
step_count: result.steps.len(),
|
||||
manifest_version: result.manifest_version,
|
||||
steps: result.steps,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_output(commit: &GraphCommit) -> CommitOutput {
|
||||
CommitOutput {
|
||||
graph_commit_id: commit.graph_commit_id.clone(),
|
||||
manifest_branch: commit.manifest_branch.clone(),
|
||||
manifest_version: commit.manifest_version,
|
||||
parent_commit_id: commit.parent_commit_id.clone(),
|
||||
merged_parent_commit_id: commit.merged_parent_commit_id.clone(),
|
||||
actor_id: commit.actor_id.clone(),
|
||||
created_at: commit.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_output(query_name: String, target: &ReadTarget, result: QueryResult) -> ReadOutput {
|
||||
let columns = result
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.name().clone())
|
||||
.collect();
|
||||
ReadOutput {
|
||||
query_name,
|
||||
target: read_target_output(target),
|
||||
row_count: result.num_rows(),
|
||||
columns,
|
||||
rows: result.to_rust_json(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ingest_output(uri: &str, result: &IngestResult, actor_id: Option<String>) -> IngestOutput {
|
||||
IngestOutput {
|
||||
uri: uri.to_string(),
|
||||
branch: result.branch.clone(),
|
||||
base_branch: result.base_branch.clone(),
|
||||
branch_created: result.branch_created,
|
||||
mode: result.mode,
|
||||
tables: result
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| IngestTableOutput {
|
||||
table_key: table.table_key.clone(),
|
||||
rows_loaded: table.rows_loaded,
|
||||
})
|
||||
.collect(),
|
||||
actor_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_target_output(target: &ReadTarget) -> ReadTargetOutput {
|
||||
match target {
|
||||
ReadTarget::Branch(branch) => ReadTargetOutput {
|
||||
branch: Some(branch.clone()),
|
||||
snapshot: None,
|
||||
},
|
||||
ReadTarget::Snapshot(snapshot) => ReadTargetOutput {
|
||||
branch: None,
|
||||
snapshot: Some(snapshot.as_str().to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MR-668 — management endpoint shapes ──────────────────────────────────
|
||||
|
||||
/// One entry in the response from `GET /graphs`. Cluster operators
|
||||
/// consume this list to discover which graphs the server is currently
|
||||
/// serving. The shape is intentionally minimal — `graph_id` and `uri`
|
||||
/// are the only fields a routing client needs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct GraphInfo {
|
||||
pub graph_id: String,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
/// Response from `GET /graphs`. Lists every graph registered with the
|
||||
/// server in alphabetical order by `graph_id` (sorted server-side so
|
||||
/// clients get deterministic output across requests).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct GraphListResponse {
|
||||
pub graphs: Vec<GraphInfo>,
|
||||
}
|
||||
|
|
|
|||
542
crates/omnigraph-server/src/config.rs
Normal file
542
crates/omnigraph-server/src/config.rs
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use color_eyre::eyre::{Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DEFAULT_CONFIG_FILE: &str = "omnigraph.yaml";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectConfig {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetConfig {
|
||||
pub uri: String,
|
||||
pub bearer_token_env: Option<String>,
|
||||
/// Per-graph Cedar policy file (MR-668). In single-graph mode this
|
||||
/// field is unused — the top-level `policy.file` applies. In
|
||||
/// multi-graph mode, each `graphs.<id>.policy.file` governs that
|
||||
/// graph's HTTP-layer Cedar enforcement.
|
||||
#[serde(default)]
|
||||
pub policy: PolicySettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReadOutputFormat {
|
||||
#[default]
|
||||
Table,
|
||||
Kv,
|
||||
Csv,
|
||||
Jsonl,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TableCellLayout {
|
||||
#[default]
|
||||
Truncate,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CliDefaults {
|
||||
#[serde(rename = "graph")]
|
||||
pub graph: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub output_format: Option<ReadOutputFormat>,
|
||||
pub table_max_column_width: Option<usize>,
|
||||
pub table_cell_layout: Option<TableCellLayout>,
|
||||
/// Default actor identity for CLI direct-engine writes (MR-722).
|
||||
/// Used when `policy.file` is configured and the operator hasn't
|
||||
/// passed `--as <actor>` on the command line. With policy configured
|
||||
/// and neither this nor `--as` set, the engine-layer footgun guard
|
||||
/// fires (no silent bypass).
|
||||
pub actor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ServerDefaults {
|
||||
#[serde(rename = "graph")]
|
||||
pub graph: Option<String>,
|
||||
pub bind: Option<String>,
|
||||
/// Server-level Cedar policy (MR-668). Governs management endpoints
|
||||
/// — currently `GET /graphs`; future runtime add/remove endpoints
|
||||
/// will plug in here too. In single-graph mode this is unused — the
|
||||
/// top-level `policy.file` covers the single graph.
|
||||
#[serde(default)]
|
||||
pub policy: PolicySettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AuthDefaults {
|
||||
pub env_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QueryDefaults {
|
||||
#[serde(default)]
|
||||
pub roots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PolicySettings {
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AliasCommand {
|
||||
/// Read alias (canonical: `query`). The legacy spelling `read` is
|
||||
/// kept as the variant name for back-compat with serialized configs
|
||||
/// and external SDK callers; `query` is accepted on the wire via the
|
||||
/// serde alias.
|
||||
#[serde(alias = "query")]
|
||||
Read,
|
||||
/// Mutation alias (canonical: `mutate`). The legacy spelling `change`
|
||||
/// is kept as the variant name for back-compat; `mutate` is accepted
|
||||
/// on the wire via the serde alias.
|
||||
#[serde(alias = "mutate")]
|
||||
Change,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AliasConfig {
|
||||
pub command: AliasCommand,
|
||||
pub query: String,
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
#[serde(rename = "graph")]
|
||||
pub graph: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub format: Option<ReadOutputFormat>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OmnigraphConfig {
|
||||
#[serde(default)]
|
||||
pub project: ProjectConfig,
|
||||
#[serde(default, rename = "graphs")]
|
||||
pub graphs: BTreeMap<String, TargetConfig>,
|
||||
#[serde(default)]
|
||||
pub server: ServerDefaults,
|
||||
#[serde(default)]
|
||||
pub auth: AuthDefaults,
|
||||
#[serde(default)]
|
||||
pub cli: CliDefaults,
|
||||
#[serde(default)]
|
||||
pub query: QueryDefaults,
|
||||
#[serde(default)]
|
||||
pub aliases: BTreeMap<String, AliasConfig>,
|
||||
#[serde(default)]
|
||||
pub policy: PolicySettings,
|
||||
#[serde(skip)]
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for OmnigraphConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
project: ProjectConfig::default(),
|
||||
graphs: BTreeMap::new(),
|
||||
server: ServerDefaults::default(),
|
||||
auth: AuthDefaults::default(),
|
||||
cli: CliDefaults::default(),
|
||||
query: QueryDefaults::default(),
|
||||
aliases: BTreeMap::new(),
|
||||
policy: PolicySettings::default(),
|
||||
base_dir: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OmnigraphConfig {
|
||||
pub fn base_dir(&self) -> &Path {
|
||||
&self.base_dir
|
||||
}
|
||||
|
||||
pub fn cli_branch(&self) -> &str {
|
||||
self.cli.branch.as_deref().unwrap_or("main")
|
||||
}
|
||||
|
||||
pub fn cli_output_format(&self) -> ReadOutputFormat {
|
||||
self.cli.output_format.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn table_max_column_width(&self) -> usize {
|
||||
self.cli.table_max_column_width.unwrap_or(80)
|
||||
}
|
||||
|
||||
pub fn table_cell_layout(&self) -> TableCellLayout {
|
||||
self.cli.table_cell_layout.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn cli_graph_name(&self) -> Option<&str> {
|
||||
self.cli.graph.as_deref()
|
||||
}
|
||||
|
||||
pub fn server_graph_name(&self) -> Option<&str> {
|
||||
self.server.graph.as_deref()
|
||||
}
|
||||
|
||||
pub fn server_bind(&self) -> &str {
|
||||
self.server.bind.as_deref().unwrap_or("127.0.0.1:8080")
|
||||
}
|
||||
|
||||
pub fn resolve_target_name<'a>(
|
||||
&self,
|
||||
explicit_uri: Option<&str>,
|
||||
explicit_target: Option<&'a str>,
|
||||
default_target: Option<&'a str>,
|
||||
) -> Option<&'a str> {
|
||||
explicit_target.or_else(|| {
|
||||
if explicit_uri.is_some() {
|
||||
None
|
||||
} else {
|
||||
default_target
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn graph_bearer_token_env(
|
||||
&self,
|
||||
explicit_uri: Option<&str>,
|
||||
explicit_target: Option<&str>,
|
||||
default_target: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
let target_name =
|
||||
self.resolve_target_name(explicit_uri, explicit_target, default_target)?;
|
||||
self.graphs
|
||||
.get(target_name)
|
||||
.and_then(|target| target.bearer_token_env.as_deref())
|
||||
}
|
||||
|
||||
pub fn resolve_auth_env_file(&self) -> Option<PathBuf> {
|
||||
self.auth
|
||||
.env_file
|
||||
.as_deref()
|
||||
.map(|path| self.resolve_config_path(path))
|
||||
}
|
||||
|
||||
pub fn resolve_policy_file(&self) -> Option<PathBuf> {
|
||||
self.policy
|
||||
.file
|
||||
.as_deref()
|
||||
.map(|path| self.resolve_config_path(path))
|
||||
}
|
||||
|
||||
/// Resolve the per-graph policy file path for the named target,
|
||||
/// relative to the config file's `base_dir`. Returns `None` if the
|
||||
/// target is unknown or no per-graph `policy.file` is set.
|
||||
pub fn resolve_target_policy_file(&self, target_name: &str) -> Option<PathBuf> {
|
||||
let target = self.graphs.get(target_name)?;
|
||||
target
|
||||
.policy
|
||||
.file
|
||||
.as_deref()
|
||||
.map(|path| self.resolve_config_path(path))
|
||||
}
|
||||
|
||||
/// Resolve the server-level policy file path (used by management
|
||||
/// endpoints). Returns `None` if `server.policy.file` is not set.
|
||||
pub fn resolve_server_policy_file(&self) -> Option<PathBuf> {
|
||||
self.server
|
||||
.policy
|
||||
.file
|
||||
.as_deref()
|
||||
.map(|path| self.resolve_config_path(path))
|
||||
}
|
||||
|
||||
/// Resolve a raw config-supplied URI (which may be relative) to its
|
||||
/// absolute form. URIs containing `://` are passed through as-is;
|
||||
/// relative paths are joined with the config file's `base_dir`.
|
||||
pub fn resolve_uri_value(&self, value: &str) -> String {
|
||||
self.resolve_config_uri(value)
|
||||
}
|
||||
|
||||
pub fn resolve_policy_tests_file(&self) -> Option<PathBuf> {
|
||||
let policy_file = self.resolve_policy_file()?;
|
||||
Some(policy_file.with_file_name("policy.tests.yaml"))
|
||||
}
|
||||
|
||||
pub fn alias(&self, name: &str) -> Result<&AliasConfig> {
|
||||
self.aliases
|
||||
.get(name)
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("alias '{}' not found", name))
|
||||
}
|
||||
|
||||
pub fn resolve_target_uri(
|
||||
&self,
|
||||
explicit_uri: Option<String>,
|
||||
explicit_target: Option<&str>,
|
||||
default_target: Option<&str>,
|
||||
) -> Result<String> {
|
||||
if let Some(uri) = explicit_uri {
|
||||
return Ok(uri);
|
||||
}
|
||||
|
||||
let target_name = explicit_target.or(default_target).ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("URI must be provided via <URI>, --target, or config")
|
||||
})?;
|
||||
let target = self.graphs.get(target_name).ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"graph '{}' not found in {}",
|
||||
target_name,
|
||||
DEFAULT_CONFIG_FILE
|
||||
)
|
||||
})?;
|
||||
Ok(self.resolve_config_uri(&target.uri))
|
||||
}
|
||||
|
||||
pub fn resolve_query_path(&self, query: &Path) -> Result<PathBuf> {
|
||||
if query.is_absolute() {
|
||||
return Ok(query.to_path_buf());
|
||||
}
|
||||
|
||||
let direct = self.base_dir.join(query);
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
for root in &self.query.roots {
|
||||
let candidate = self.base_dir.join(root).join(query);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
bail!("query file '{}' not found", query.display());
|
||||
}
|
||||
|
||||
fn resolve_config_uri(&self, value: &str) -> String {
|
||||
if value.contains("://") {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let path = Path::new(value);
|
||||
if path.is_absolute() {
|
||||
value.to_string()
|
||||
} else {
|
||||
self.base_dir.join(path).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_config_path(&self, value: &str) -> PathBuf {
|
||||
let path = Path::new(value);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
self.base_dir.join(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
PathBuf::from(DEFAULT_CONFIG_FILE)
|
||||
}
|
||||
|
||||
pub fn load_config(config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
|
||||
load_config_in(&env::current_dir()?, config_path)
|
||||
}
|
||||
|
||||
fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
|
||||
let explicit_path = config_path.cloned();
|
||||
let config_path = explicit_path.or_else(|| {
|
||||
let default_path = cwd.join(DEFAULT_CONFIG_FILE);
|
||||
default_path.exists().then_some(default_path)
|
||||
});
|
||||
|
||||
let mut config = if let Some(path) = &config_path {
|
||||
serde_yaml::from_str::<OmnigraphConfig>(&fs::read_to_string(path)?)?
|
||||
} else {
|
||||
OmnigraphConfig::default()
|
||||
};
|
||||
|
||||
config.base_dir = if let Some(path) = config_path {
|
||||
absolute_base_dir(cwd, &path)?
|
||||
} else {
|
||||
cwd.to_path_buf()
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn absolute_base_dir(cwd: &Path, path: &Path) -> Result<PathBuf> {
|
||||
let path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
};
|
||||
Ok(path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| cwd.to_path_buf()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::{ReadOutputFormat, TableCellLayout, load_config_in};
|
||||
|
||||
#[test]
|
||||
fn load_config_reads_yaml_defaults_from_current_dir() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
r#"
|
||||
graphs:
|
||||
local:
|
||||
uri: ./demo.omni
|
||||
bearer_token_env: DEMO_TOKEN
|
||||
auth:
|
||||
env_file: .env.omni
|
||||
cli:
|
||||
graph: local
|
||||
branch: main
|
||||
output_format: kv
|
||||
table_max_column_width: 40
|
||||
table_cell_layout: wrap
|
||||
policy: {}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
assert_eq!(config.cli_graph_name(), Some("local"));
|
||||
assert_eq!(config.cli_branch(), "main");
|
||||
assert_eq!(config.cli_output_format(), ReadOutputFormat::Kv);
|
||||
assert_eq!(config.table_max_column_width(), 40);
|
||||
assert_eq!(config.table_cell_layout(), TableCellLayout::Wrap);
|
||||
assert_eq!(
|
||||
config.graph_bearer_token_env(None, None, config.cli_graph_name()),
|
||||
Some("DEMO_TOKEN")
|
||||
);
|
||||
assert_eq!(
|
||||
config.resolve_auth_env_file().unwrap(),
|
||||
temp.path().join(".env.omni")
|
||||
);
|
||||
assert_eq!(
|
||||
PathBuf::from(
|
||||
config
|
||||
.resolve_target_uri(None, None, config.cli_graph_name())
|
||||
.unwrap()
|
||||
),
|
||||
temp.path().join("./demo.omni")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_does_not_walk_parent_directories() {
|
||||
let temp = tempdir().unwrap();
|
||||
let child = temp.path().join("child");
|
||||
fs::create_dir_all(&child).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"graphs:\n local:\n uri: ./demo.omni\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(&child, None).unwrap();
|
||||
assert!(config.graphs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_query_path_searches_config_roots() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::create_dir_all(temp.path().join("queries")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"query:\n roots:\n - queries\npolicy: {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("queries").join("test.gq"),
|
||||
"query q { return {} }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
let resolved = config.resolve_query_path(Path::new("test.gq")).unwrap();
|
||||
assert_eq!(resolved, temp.path().join("queries").join("test.gq"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_query_path_prefers_config_base_dir_over_ambient_cwd() {
|
||||
let workspace = tempdir().unwrap();
|
||||
let config_dir = workspace.path().join("config");
|
||||
let ambient_dir = workspace.path().join("ambient");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::create_dir_all(&ambient_dir).unwrap();
|
||||
fs::write(config_dir.join("omnigraph.yaml"), "policy: {}\n").unwrap();
|
||||
fs::write(config_dir.join("local.gq"), "query local { return {} }").unwrap();
|
||||
fs::write(ambient_dir.join("local.gq"), "query ambient { return {} }").unwrap();
|
||||
|
||||
let config =
|
||||
load_config_in(&ambient_dir, Some(&config_dir.join("omnigraph.yaml"))).unwrap();
|
||||
let resolved = config.resolve_query_path(Path::new("local.gq")).unwrap();
|
||||
|
||||
assert_eq!(resolved, config_dir.join("local.gq"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_block_accepts_non_empty_mapping() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"policy:\n file: ./policy.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
config.resolve_policy_file().unwrap(),
|
||||
temp.path().join("policy.yaml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_auth_env_ignores_default_target_when_uri_is_explicit() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
r#"
|
||||
graphs:
|
||||
demo:
|
||||
uri: https://example.com
|
||||
bearer_token_env: DEMO_TOKEN
|
||||
cli:
|
||||
graph: demo
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
config.graph_bearer_token_env(
|
||||
Some("https://override.example.com"),
|
||||
None,
|
||||
config.cli_graph_name()
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
config.graph_bearer_token_env(
|
||||
Some("https://override.example.com"),
|
||||
Some("demo"),
|
||||
config.cli_graph_name()
|
||||
),
|
||||
Some("DEMO_TOKEN")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -8,12 +8,12 @@ use omnigraph_server::{ServerConfig, init_tracing, load_server_settings, serve};
|
|||
#[command(name = "omnigraph-server")]
|
||||
#[command(about = "HTTP server for the Omnigraph graph database")]
|
||||
struct Cli {
|
||||
/// Boot from a cluster: either a config directory (storage resolved
|
||||
/// through cluster.yaml) or a storage-root URI directly
|
||||
/// (s3://bucket/prefix — config-free serving from the bucket).
|
||||
/// The server's only boot source (RFC-011 cluster-only).
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
cluster: Option<PathBuf>,
|
||||
target: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
bind: Option<String>,
|
||||
/// Run without bearer tokens and without a policy file (MR-723).
|
||||
|
|
@ -22,11 +22,6 @@ struct Cli {
|
|||
/// Equivalent to setting `OMNIGRAPH_UNAUTHENTICATED=1`.
|
||||
#[arg(long)]
|
||||
unauthenticated: bool,
|
||||
/// Fail startup if any applied graph is quarantined or fails to open.
|
||||
/// By default, graph-local failures are logged and healthy graphs still
|
||||
/// serve. Equivalent to setting `OMNIGRAPH_REQUIRE_ALL_GRAPHS=1`.
|
||||
#[arg(long)]
|
||||
require_all_graphs: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -36,11 +31,11 @@ async fn main() -> Result<()> {
|
|||
|
||||
let cli = Cli::parse();
|
||||
let settings: ServerConfig = load_server_settings(
|
||||
cli.cluster.as_ref(),
|
||||
cli.config.as_ref(),
|
||||
cli.uri,
|
||||
cli.target,
|
||||
cli.bind,
|
||||
cli.unauthenticated,
|
||||
cli.require_all_graphs,
|
||||
)
|
||||
.await?;
|
||||
)?;
|
||||
serve(settings).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,613 +0,0 @@
|
|||
//! Stored-query registry.
|
||||
//!
|
||||
//! A server-side registry of named, parameter-typed `.gq` queries that
|
||||
//! operators declare in `omnigraph.yaml` (per-graph, or top-level in
|
||||
//! single mode) and the server loads at startup. Each entry is parsed
|
||||
//! and its identity asserted here (`load`); type-checking against the
|
||||
//! live schema happens separately (a `check` pass) so the loader stays
|
||||
//! callable without an open engine (the CLI's offline `queries check`).
|
||||
//!
|
||||
//! Identity is the query **name**: the manifest key must equal the
|
||||
//! `query <name>` symbol declared in the referenced `.gq` file. The two
|
||||
//! are asserted equal at load — one name, two places that must agree.
|
||||
//! Renaming either is a breaking change to callers, by design.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use omnigraph_compiler::catalog::Catalog;
|
||||
use omnigraph_compiler::query::ast::QueryDecl;
|
||||
use omnigraph_compiler::query::parser::parse_query;
|
||||
use omnigraph_compiler::query::typecheck::typecheck_query_decl;
|
||||
use omnigraph_compiler::types::{PropType, ScalarType};
|
||||
|
||||
/// One loaded stored query. `source` is the full `.gq` file text — the
|
||||
/// invocation handler hands it to `run_query` / `run_mutate` verbatim,
|
||||
/// which reuse the same parse/IR/exec path as the inline routes (no
|
||||
/// parallel implementation).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredQuery {
|
||||
/// Identity: manifest key == `query <name>` symbol.
|
||||
pub name: String,
|
||||
/// Full `.gq` source text the query was selected from.
|
||||
pub source: Arc<str>,
|
||||
/// Parsed declaration (params, mutations, description, …).
|
||||
pub decl: QueryDecl,
|
||||
/// Whether this query is listed in the MCP tool catalog (`GET /queries`).
|
||||
/// Default `true` (the manifest entry is the opt-in); `expose: false`
|
||||
/// keeps it HTTP/service-callable but hidden from the agent tool list.
|
||||
/// Catalog membership only — not an authorization gate.
|
||||
pub expose: bool,
|
||||
/// Optional MCP tool-name override; defaults to `name`.
|
||||
pub tool_name: Option<String>,
|
||||
}
|
||||
|
||||
impl StoredQuery {
|
||||
/// `true` if the selected declaration contains insert/update/delete
|
||||
/// statements — drives read-vs-mutate routing at invocation time.
|
||||
pub fn is_mutation(&self) -> bool {
|
||||
!self.decl.mutations.is_empty()
|
||||
}
|
||||
|
||||
/// The MCP tool name this query is catalogued under: the explicit
|
||||
/// `tool_name` override, else the query `name`. The catalog key —
|
||||
/// enforced unique across exposed queries at load. Server-side
|
||||
/// consumers (the uniqueness check, the future catalog projection) read
|
||||
/// this; the CLI `queries list` resolves the same rule on its own DTO.
|
||||
pub fn effective_tool_name(&self) -> &str {
|
||||
self.tool_name.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded, identity-checked stored-query registry for one graph.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QueryRegistry {
|
||||
by_name: BTreeMap<String, StoredQuery>,
|
||||
}
|
||||
|
||||
/// In-memory registry spec: a query's name + already-read `.gq` source. The
|
||||
/// input to [`QueryRegistry::from_specs`] — built by the server's cluster boot
|
||||
/// and by the CLI's `queries` tooling from a cluster serving snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistrySpec {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub expose: bool,
|
||||
pub tool_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A single registry load failure. Collected (not fail-fast) so a bad
|
||||
/// `omnigraph.yaml` surfaces every broken entry at once, matching the
|
||||
/// bad-policy-YAML posture.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadError {
|
||||
/// The offending query name, when the failure is entry-scoped.
|
||||
pub query: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.query {
|
||||
Some(name) => write!(f, "stored query '{name}': {}", self.message),
|
||||
None => write!(f, "stored query registry: {}", self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueryRegistry {
|
||||
/// Build a registry from in-memory specs: parse each source, select
|
||||
/// the declaration whose symbol equals the manifest key, and assert
|
||||
/// they agree. Collects every failure. No schema type-checking here
|
||||
/// — that is [`check`].
|
||||
pub fn from_specs(specs: Vec<RegistrySpec>) -> Result<Self, Vec<LoadError>> {
|
||||
let mut by_name = BTreeMap::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for spec in specs {
|
||||
match parse_query(&spec.source) {
|
||||
Ok(file) => {
|
||||
match file.queries.into_iter().find(|q| q.name == spec.name) {
|
||||
Some(decl) => {
|
||||
by_name.insert(
|
||||
spec.name.clone(),
|
||||
StoredQuery {
|
||||
name: spec.name,
|
||||
source: Arc::from(spec.source),
|
||||
decl,
|
||||
expose: spec.expose,
|
||||
tool_name: spec.tool_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
None => errors.push(LoadError {
|
||||
query: Some(spec.name.clone()),
|
||||
message: format!(
|
||||
"no `query {}` declaration found in its `.gq` file \
|
||||
(the registry key must match the query symbol)",
|
||||
spec.name
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Err(err) => errors.push(LoadError {
|
||||
query: Some(spec.name),
|
||||
message: format!("parse error: {err}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Exposed queries are catalogued under their effective tool name;
|
||||
// two claiming one name is an MCP-namespace collision. Refuse it at
|
||||
// load (collected, not fail-fast), naming the loser and the winner.
|
||||
// Iterating the `BTreeMap` makes the winner deterministic (the
|
||||
// lexicographically-first query name; config is a map, so YAML
|
||||
// declaration order isn't preserved anyway) and the error order
|
||||
// stable. Scoped to a block so these borrows of `by_name` end
|
||||
// before it is moved into `Self`.
|
||||
{
|
||||
let mut claimed: BTreeMap<&str, &str> = BTreeMap::new();
|
||||
for query in by_name.values().filter(|q| q.expose) {
|
||||
let tool = query.effective_tool_name();
|
||||
if let Some(winner) = claimed.insert(tool, &query.name) {
|
||||
errors.push(LoadError {
|
||||
query: Some(query.name.clone()),
|
||||
message: format!(
|
||||
"MCP tool name '{tool}' already claimed by exposed query '{winner}'"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(Self { by_name })
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<&StoredQuery> {
|
||||
self.by_name.get(name)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &StoredQuery> {
|
||||
self.by_name.values()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_name.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_name.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored query that fails to type-check against the live schema —
|
||||
/// e.g. it references a node/edge type or property that was renamed or
|
||||
/// removed by a migration. Breakages **block server boot** (same posture
|
||||
/// as bad policy YAML), surfacing schema drift at the deploy boundary
|
||||
/// rather than silently at invocation time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Breakage {
|
||||
pub query: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A non-blocking advisory found during validation. Logged at boot;
|
||||
/// never blocks startup. Currently: an MCP-exposed query that declares a
|
||||
/// parameter an agent cannot realistically supply.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Warning {
|
||||
pub query: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Outcome of validating a registry against a schema. Breakages are
|
||||
/// fatal (boot refuses); warnings are advisory.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CheckReport {
|
||||
pub breakages: Vec<Breakage>,
|
||||
pub warnings: Vec<Warning>,
|
||||
}
|
||||
|
||||
impl CheckReport {
|
||||
pub fn has_breakages(&self) -> bool {
|
||||
!self.breakages.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.breakages.is_empty() && self.warnings.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a loaded registry against the live schema.
|
||||
///
|
||||
/// Pure over `(registry, catalog)` — takes an already-parsed registry and
|
||||
/// a catalog, so it is callable both at server boot (with the engine's
|
||||
/// `catalog()`) and offline from the CLI (`omnigraph queries check`),
|
||||
/// without coupling to server config or an open engine connection.
|
||||
///
|
||||
/// Every query is type-checked via the same `typecheck_query_decl` the
|
||||
/// engine runs for inline queries — no parallel implementation. Failures
|
||||
/// are **collected, not fail-fast**, so an operator sees every broken
|
||||
/// query in one pass.
|
||||
///
|
||||
/// Advisory lint (warn, never block): an `mcp.expose: true` query that
|
||||
/// declares a `Vector(N)` parameter. An LLM cannot supply a raw embedding
|
||||
/// vector; such a query should take a `String` parameter and let the
|
||||
/// engine embed it server-side at query time. Service-to-service callers
|
||||
/// may legitimately pass vectors, so this warns rather than rejects.
|
||||
pub fn check(registry: &QueryRegistry, catalog: &Catalog) -> CheckReport {
|
||||
let mut report = CheckReport::default();
|
||||
for query in registry.iter() {
|
||||
if let Err(err) = typecheck_query_decl(catalog, &query.decl) {
|
||||
report.breakages.push(Breakage {
|
||||
query: query.name.clone(),
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
if query.expose {
|
||||
for param in &query.decl.params {
|
||||
// Resolve to the structured type via the compiler's own
|
||||
// resolver rather than string-matching `Vector(` — one
|
||||
// canonical definition of "is a vector", so this lint can't
|
||||
// drift from how the parser/type system spells the type.
|
||||
let is_vector = PropType::from_param_type_name(¶m.type_name, param.nullable)
|
||||
.is_some_and(|pt| matches!(pt.scalar, ScalarType::Vector(_)));
|
||||
if is_vector {
|
||||
report.warnings.push(Warning {
|
||||
query: query.name.clone(),
|
||||
message: format!(
|
||||
"MCP-exposed query declares a `{}` parameter `${}` that agents \
|
||||
cannot supply; use a `String` parameter for server-side embedding",
|
||||
param.type_name, param.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Format every breakage in a registry check report into a multi-line
|
||||
/// operator-facing message, naming each offending query.
|
||||
pub fn format_check_breakages(label: &str, report: &CheckReport) -> String {
|
||||
let joined = report
|
||||
.breakages
|
||||
.iter()
|
||||
.map(|b| format!("query '{}': {}", b.query, b.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ");
|
||||
format!(
|
||||
"graph '{label}': {} stored quer{} failed the schema check:\n {joined}",
|
||||
report.breakages.len(),
|
||||
if report.breakages.len() == 1 {
|
||||
"y"
|
||||
} else {
|
||||
"ies"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spec(name: &str, source: &str, expose: bool) -> RegistrySpec {
|
||||
RegistrySpec {
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
expose,
|
||||
tool_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn spec_tool(name: &str, source: &str, expose: bool, tool_name: &str) -> RegistrySpec {
|
||||
RegistrySpec {
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
expose,
|
||||
tool_name: Some(tool_name.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_equal_symbol_loads() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"find_user",
|
||||
"query find_user($id: String) { match { $u: User } return { $u.name } }",
|
||||
true,
|
||||
)])
|
||||
.unwrap();
|
||||
let q = reg.lookup("find_user").unwrap();
|
||||
assert_eq!(q.name, "find_user");
|
||||
assert!(q.expose);
|
||||
assert_eq!(q.decl.params.len(), 1);
|
||||
assert!(!q.is_mutation());
|
||||
// No override → the effective tool name is the query name.
|
||||
assert_eq!(q.effective_tool_name(), "find_user");
|
||||
|
||||
// An explicit override is what the catalog keys on.
|
||||
let with_tool = QueryRegistry::from_specs(vec![spec_tool(
|
||||
"find_user",
|
||||
"query find_user($id: String) { match { $u: User } return { $u.name } }",
|
||||
true,
|
||||
"lookup_user",
|
||||
)])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
with_tool.lookup("find_user").unwrap().effective_tool_name(),
|
||||
"lookup_user"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_mismatch_is_an_identity_error() {
|
||||
let errors = QueryRegistry::from_specs(vec![spec(
|
||||
"find_user",
|
||||
// symbol is `lookup`, key is `find_user` — must be rejected.
|
||||
"query lookup($id: String) { match { $u: User } return { $u.name } }",
|
||||
false,
|
||||
)])
|
||||
.unwrap_err();
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert_eq!(errors[0].query.as_deref(), Some("find_user"));
|
||||
assert!(errors[0].message.contains("must match the query symbol"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_query_file_selects_the_matching_symbol() {
|
||||
let source = "query a($x: I64) { match { $u: User } return { $u.name } }\n\
|
||||
query b($y: String) { match { $u: User } return { $u.name } }";
|
||||
let reg = QueryRegistry::from_specs(vec![spec("b", source, false)]).unwrap();
|
||||
let q = reg.lookup("b").unwrap();
|
||||
assert_eq!(q.name, "b");
|
||||
assert_eq!(q.decl.params[0].name, "y");
|
||||
assert!(reg.lookup("a").is_none(), "only the selected symbol is registered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_exposed_tool_name_is_a_load_error() {
|
||||
// Two MCP-exposed queries claiming one tool name is an ambiguity in
|
||||
// the catalog key space — refused at load, naming both queries and
|
||||
// the contested tool.
|
||||
let errors = QueryRegistry::from_specs(vec![
|
||||
spec_tool("a", "query a() { match { $u: User } return { $u.name } }", true, "dup"),
|
||||
spec_tool("b", "query b() { match { $u: User } return { $u.name } }", true, "dup"),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert_eq!(errors.len(), 1);
|
||||
let msg = errors[0].to_string();
|
||||
assert!(msg.contains("'dup'"), "names the contested tool: {msg}");
|
||||
assert!(msg.contains("'a'"), "names the winning query: {msg}");
|
||||
assert!(msg.contains("'b'"), "names the losing query: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tool_name_among_unexposed_is_allowed() {
|
||||
// Unexposed queries have no MCP tool, so a shared effective tool
|
||||
// name is inert — must not error (pins the exposed-only scope).
|
||||
let reg = QueryRegistry::from_specs(vec![
|
||||
spec_tool("a", "query a() { match { $u: User } return { $u.name } }", false, "dup"),
|
||||
spec_tool("b", "query b() { match { $u: User } return { $u.name } }", false, "dup"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(reg.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_error_surfaces_per_entry() {
|
||||
let errors =
|
||||
QueryRegistry::from_specs(vec![spec("broken", "query broken( {{ not valid", false)])
|
||||
.unwrap_err();
|
||||
assert_eq!(errors[0].query.as_deref(), Some("broken"));
|
||||
assert!(errors[0].message.contains("parse error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_collect_rather_than_fail_fast() {
|
||||
let errors = QueryRegistry::from_specs(vec![
|
||||
spec("good", "query good() { match { $u: User } return { $u.name } }", false),
|
||||
spec("mismatch", "query other() { match { $u: User } return { $u.name } }", false),
|
||||
spec("broken", "query broken(", false),
|
||||
])
|
||||
.unwrap_err();
|
||||
// `good` loads cleanly; only the mismatch and the parse error are
|
||||
// reported, and both surface in one pass (not fail-fast).
|
||||
assert_eq!(errors.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_body_classifies_as_mutation() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"add_user",
|
||||
"query add_user($name: String) { insert User { name: $name } }",
|
||||
false,
|
||||
)])
|
||||
.unwrap();
|
||||
assert!(reg.lookup("add_user").unwrap().is_mutation());
|
||||
}
|
||||
|
||||
// --- check(registry, catalog) ---
|
||||
|
||||
use omnigraph_compiler::catalog::build_catalog;
|
||||
use omnigraph_compiler::schema::parser::parse_schema;
|
||||
|
||||
fn test_catalog() -> Catalog {
|
||||
let schema = parse_schema(
|
||||
r#"
|
||||
node User {
|
||||
name: String
|
||||
age: I32?
|
||||
embedding: Vector(4)
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
build_catalog(&schema).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_passes_for_valid_query() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"find_user",
|
||||
"query find_user($name: String) { match { $u: User { name: $name } } return { $u.age } }",
|
||||
false,
|
||||
)])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert!(report.is_clean(), "unexpected: {:?}", report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_unknown_type_as_breakage() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"ghost",
|
||||
// `Widget` is not in the schema.
|
||||
"query ghost() { match { $w: Widget } return { $w.name } }",
|
||||
false,
|
||||
)])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert!(report.has_breakages());
|
||||
assert_eq!(report.breakages[0].query, "ghost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_unknown_property_as_breakage() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"bad_prop",
|
||||
// `User` exists but has no `nickname`.
|
||||
"query bad_prop() { match { $u: User } return { $u.nickname } }",
|
||||
false,
|
||||
)])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert!(report.has_breakages());
|
||||
assert_eq!(report.breakages[0].query, "bad_prop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_collects_every_breakage_not_fail_fast() {
|
||||
let reg = QueryRegistry::from_specs(vec![
|
||||
spec("a", "query a() { match { $w: Widget } return { $w.x } }", false),
|
||||
spec("b", "query b() { match { $g: Gadget } return { $g.y } }", false),
|
||||
spec(
|
||||
"ok",
|
||||
"query ok() { match { $u: User } return { $u.name } }",
|
||||
false,
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert_eq!(report.breakages.len(), 2, "both bad queries reported: {:?}", report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_param_on_exposed_query_warns() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"vec_search",
|
||||
"query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
|
||||
order { nearest($u.embedding, $q) } limit 3 }",
|
||||
true, // mcp.expose
|
||||
)])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert!(!report.has_breakages(), "valid query: {:?}", report);
|
||||
assert_eq!(report.warnings.len(), 1);
|
||||
assert_eq!(report.warnings[0].query, "vec_search");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_param_on_unexposed_query_is_silent() {
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"vec_search",
|
||||
"query vec_search($q: Vector(4)) { match { $u: User } return { $u.name } \
|
||||
order { nearest($u.embedding, $q) } limit 3 }",
|
||||
false, // not exposed — vector param is fine for service-to-service callers
|
||||
)])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert!(report.is_clean(), "unexpected: {:?}", report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_vector_param_on_exposed_query_does_not_warn() {
|
||||
// The recommended `String` alternative on an exposed query does not
|
||||
// resolve to a Vector, so the embedding advisory stays silent. Guards
|
||||
// the structured type check against a false positive (and pins that
|
||||
// only `Vector(_)` triggers the warning).
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"search",
|
||||
"query search($name: String) { match { $u: User { name: $name } } return { $u.name } }",
|
||||
true,
|
||||
)])
|
||||
.unwrap();
|
||||
let report = check(®, &test_catalog());
|
||||
assert!(report.is_clean(), "no breakage or warning expected: {:?}", report);
|
||||
}
|
||||
|
||||
// --- catalog projection (api::query_catalog_entry) ---
|
||||
|
||||
#[test]
|
||||
fn catalog_entry_projects_every_param_kind() {
|
||||
use crate::api::{self, ParamKind};
|
||||
let reg = QueryRegistry::from_specs(vec![spec_tool(
|
||||
"all_types",
|
||||
"query all_types($s: String, $i: I32, $big: I64, $u: U64, $f: F64, $b: Bool, \
|
||||
$d: Date, $dt: DateTime, $blob: Blob, $opt: String?, $list: [I32], $vec: Vector(4)) \
|
||||
{ match { $x: User } return { $x.name } }",
|
||||
true,
|
||||
"all",
|
||||
)])
|
||||
.unwrap();
|
||||
let entry = api::query_catalog_entry(reg.lookup("all_types").unwrap());
|
||||
assert_eq!(entry.name, "all_types");
|
||||
assert_eq!(entry.tool_name, "all");
|
||||
assert!(!entry.mutation);
|
||||
|
||||
let by: std::collections::HashMap<_, _> =
|
||||
entry.params.iter().map(|p| (p.name.as_str(), p)).collect();
|
||||
assert_eq!(by["s"].kind, ParamKind::String);
|
||||
assert_eq!(by["i"].kind, ParamKind::Int);
|
||||
assert_eq!(by["big"].kind, ParamKind::BigInt, "I64 → bigint (string on the wire)");
|
||||
assert_eq!(by["u"].kind, ParamKind::BigInt, "U64 → bigint");
|
||||
assert_eq!(by["f"].kind, ParamKind::Float);
|
||||
assert_eq!(by["b"].kind, ParamKind::Bool);
|
||||
assert_eq!(by["d"].kind, ParamKind::Date);
|
||||
assert_eq!(by["dt"].kind, ParamKind::DateTime);
|
||||
assert_eq!(by["blob"].kind, ParamKind::Blob);
|
||||
assert!(!by["s"].nullable);
|
||||
assert!(by["opt"].nullable, "String? → nullable");
|
||||
assert_eq!(by["list"].kind, ParamKind::List);
|
||||
assert_eq!(by["list"].item_kind, Some(ParamKind::Int), "[I32] → list of int");
|
||||
assert_eq!(by["vec"].kind, ParamKind::Vector);
|
||||
assert_eq!(by["vec"].vector_dim, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_entry_flags_mutation_and_empty_params() {
|
||||
use crate::api;
|
||||
let reg = QueryRegistry::from_specs(vec![spec(
|
||||
"add_user",
|
||||
"query add_user($name: String) { insert User { name: $name } }",
|
||||
true,
|
||||
)])
|
||||
.unwrap();
|
||||
let entry = api::query_catalog_entry(reg.lookup("add_user").unwrap());
|
||||
assert!(entry.mutation, "insert body → mutation flag");
|
||||
|
||||
let reg2 = QueryRegistry::from_specs(vec![spec(
|
||||
"no_params",
|
||||
"query no_params() { match { $u: User } return { $u.name } }",
|
||||
true,
|
||||
)])
|
||||
.unwrap();
|
||||
let entry2 = api::query_catalog_entry(reg2.lookup("no_params").unwrap());
|
||||
assert!(entry2.params.is_empty(), "no declared params → empty list");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -29,7 +29,6 @@ use tokio::sync::Mutex;
|
|||
|
||||
use crate::identity::GraphKey;
|
||||
use crate::policy::PolicyEngine;
|
||||
use crate::queries::QueryRegistry;
|
||||
|
||||
/// Open handle for a single graph in the registry. Cheap to clone (`Arc`-wrapped
|
||||
/// engine + policy). Cluster-mode handlers extract this via
|
||||
|
|
@ -48,11 +47,6 @@ pub struct GraphHandle {
|
|||
/// `_as` writers"; the HTTP-layer `require_bearer_auth` middleware still
|
||||
/// runs regardless.
|
||||
pub policy: Option<Arc<PolicyEngine>>,
|
||||
/// Per-graph stored-query registry, loaded and validated at
|
||||
/// startup. `None` means the operator declared no stored queries for
|
||||
/// this graph — `POST /queries/{name}` then 404s. Mirrors the
|
||||
/// optional `policy` shape.
|
||||
pub queries: Option<Arc<QueryRegistry>>,
|
||||
}
|
||||
|
||||
/// Immutable snapshot of the registry's current state. Replaced atomically
|
||||
|
|
@ -251,7 +245,6 @@ fn canonicalize_handle_uri(
|
|||
uri: canonical_uri.clone(),
|
||||
engine: Arc::clone(&handle.engine),
|
||||
policy: handle.policy.clone(),
|
||||
queries: handle.queries.clone(),
|
||||
});
|
||||
Ok((canonical_uri, canonical_handle))
|
||||
}
|
||||
|
|
@ -283,7 +276,6 @@ mod tests {
|
|||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -348,14 +340,12 @@ mod tests {
|
|||
uri: shared_uri.clone(),
|
||||
engine: Arc::clone(&engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let h2 = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("beta").unwrap()),
|
||||
uri: shared_uri,
|
||||
engine,
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
|
||||
let registry = GraphRegistry::new();
|
||||
|
|
@ -421,14 +411,12 @@ mod tests {
|
|||
uri: shared_uri.clone(),
|
||||
engine: Arc::clone(&engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let h2 = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("beta").unwrap()),
|
||||
uri: shared_uri,
|
||||
engine,
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let err = match GraphRegistry::from_handles(vec![h1, h2]) {
|
||||
Ok(_) => panic!("expected DuplicateUri, got Ok"),
|
||||
|
|
|
|||
|
|
@ -1,837 +0,0 @@
|
|||
//! Server settings: cluster/CLI/env resolution, bearer-token sources, and
|
||||
//! runtime-state classification (moved verbatim from lib.rs in the
|
||||
//! modularization).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Build serving settings from a cluster directory's applied revision
|
||||
/// (RFC-005 §D2): graphs at derived roots, stored queries from verified
|
||||
/// catalog blob content, policy bundles from blob paths with their applied
|
||||
/// bindings. Always multi-graph routing.
|
||||
pub(crate) async fn load_cluster_settings(
|
||||
cluster_dir: &PathBuf,
|
||||
cli_bind: Option<String>,
|
||||
cli_allow_unauthenticated: bool,
|
||||
cli_require_all_graphs: bool,
|
||||
) -> Result<ServerConfig> {
|
||||
// `--cluster` accepts either a config directory (the ledger location is
|
||||
// resolved through cluster.yaml's `storage:` key) or a storage-root URI
|
||||
// directly (`s3://bucket/prefix`) — config-free serving: the ledger and
|
||||
// catalog on the bucket ARE the deployment artifact.
|
||||
// Any scheme-qualified argument (s3://, file://) is a storage root; a
|
||||
// bare path is a config directory.
|
||||
let cluster_arg = cluster_dir.to_string_lossy();
|
||||
let snapshot = if cluster_arg.contains("://") {
|
||||
omnigraph_cluster::read_serving_snapshot_from_storage(cluster_arg.as_ref()).await
|
||||
} else {
|
||||
omnigraph_cluster::read_serving_snapshot(cluster_dir).await
|
||||
}
|
||||
.map_err(|diagnostics| {
|
||||
let details = diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| {
|
||||
format!(
|
||||
"[{}] {}: {}",
|
||||
diagnostic.code, diagnostic.path, diagnostic.message
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ");
|
||||
eyre!(
|
||||
"the cluster at '{}' is not ready to serve:\n {details}",
|
||||
cluster_dir.display()
|
||||
)
|
||||
})?;
|
||||
for diagnostic in &snapshot.diagnostics {
|
||||
warn!(
|
||||
code = %diagnostic.code,
|
||||
path = %diagnostic.path,
|
||||
message = %diagnostic.message,
|
||||
"cluster startup diagnostic"
|
||||
);
|
||||
}
|
||||
let env_require_all_graphs = env_flag("OMNIGRAPH_REQUIRE_ALL_GRAPHS");
|
||||
let require_all_graphs = cli_require_all_graphs || env_require_all_graphs;
|
||||
if require_all_graphs && !snapshot.diagnostics.is_empty() {
|
||||
let details = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| {
|
||||
format!(
|
||||
"[{}] {}: {}",
|
||||
diagnostic.code, diagnostic.path, diagnostic.message
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ");
|
||||
bail!(
|
||||
"strict cluster boot requires every applied graph to be ready; startup diagnostics:\n {details}"
|
||||
);
|
||||
}
|
||||
|
||||
// Bindings -> Cedar slots. The serving pipeline loads one bundle per
|
||||
// graph plus one server-level bundle; stacked bundles per scope are a
|
||||
// later slice — refuse loudly rather than silently merging policy.
|
||||
let mut server_policy: Option<PolicySource> = None;
|
||||
let mut graph_policies: BTreeMap<String, PolicySource> = BTreeMap::new();
|
||||
for policy in &snapshot.policies {
|
||||
for binding in &policy.applies_to {
|
||||
if binding == "cluster" {
|
||||
if server_policy
|
||||
.replace(PolicySource::Inline(policy.source.clone()))
|
||||
.is_some()
|
||||
{
|
||||
bail!(
|
||||
"multiple policy bundles bind the cluster scope; cluster-mode serving supports one bundle per scope — split or merge bundles (multi-bundle scopes are a later slice)"
|
||||
);
|
||||
}
|
||||
} else if let Some(graph_id) = binding.strip_prefix("graph.") {
|
||||
if graph_policies
|
||||
.insert(
|
||||
graph_id.to_string(),
|
||||
PolicySource::Inline(policy.source.clone()),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
bail!(
|
||||
"multiple policy bundles bind graph '{graph_id}'; cluster-mode serving supports one bundle per scope — split or merge bundles (multi-bundle scopes are a later slice)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
bail!("unrecognized policy binding '{binding}' in the applied revision");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut graphs = Vec::new();
|
||||
let mut skipped_graphs = Vec::new();
|
||||
for graph in &snapshot.graphs {
|
||||
let specs: Vec<queries::RegistrySpec> = snapshot
|
||||
.queries
|
||||
.iter()
|
||||
.filter(|query| query.graph_id == graph.graph_id)
|
||||
.map(|query| queries::RegistrySpec {
|
||||
name: query.name.clone(),
|
||||
source: query.source.clone(),
|
||||
// The §D5 bridge: the cluster registry has no expose flag
|
||||
// (exposure becomes a policy decision in Phase 6) — cluster
|
||||
// mode lists every stored query.
|
||||
expose: true,
|
||||
tool_name: None,
|
||||
})
|
||||
.collect();
|
||||
let registry = match QueryRegistry::from_specs(specs) {
|
||||
Ok(registry) => registry,
|
||||
Err(errors) => {
|
||||
let details = errors
|
||||
.iter()
|
||||
.map(|error| error.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n ");
|
||||
warn!(
|
||||
graph_id = %graph.graph_id,
|
||||
errors = %details,
|
||||
"graph quarantined because stored queries failed to parse"
|
||||
);
|
||||
skipped_graphs.push(format!(
|
||||
"{}: stored queries failed to parse: {details}",
|
||||
graph.graph_id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let embedding = match graph
|
||||
.embedding
|
||||
.as_ref()
|
||||
.map(|profile| {
|
||||
profile.resolve().map_err(|err| {
|
||||
eyre!("embedding provider for graph '{}': {err}", graph.graph_id)
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
{
|
||||
Ok(embedding) => embedding,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
graph_id = %graph.graph_id,
|
||||
error = %err,
|
||||
"graph quarantined because embedding provider configuration failed"
|
||||
);
|
||||
skipped_graphs.push(format!("{}: {err}", graph.graph_id));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
graphs.push(GraphStartupConfig {
|
||||
graph_id: graph.graph_id.clone(),
|
||||
uri: graph.root.to_string_lossy().to_string(),
|
||||
policy: graph_policies.get(&graph.graph_id).cloned(),
|
||||
embedding,
|
||||
queries: registry,
|
||||
});
|
||||
}
|
||||
if graphs.is_empty() {
|
||||
let skipped = skipped_graphs.join(", ");
|
||||
bail!(
|
||||
"the cluster at '{}' has no healthy graphs to serve{}",
|
||||
cluster_dir.display(),
|
||||
if skipped.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" (quarantined: {skipped})")
|
||||
}
|
||||
);
|
||||
}
|
||||
if require_all_graphs && !skipped_graphs.is_empty() {
|
||||
bail!(
|
||||
"strict cluster boot requires every graph to build startup settings (quarantined: {})",
|
||||
skipped_graphs.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
let env_unauth = env_flag("OMNIGRAPH_UNAUTHENTICATED");
|
||||
|
||||
Ok(ServerConfig {
|
||||
mode: ServerConfigMode::Multi {
|
||||
graphs,
|
||||
config_path: cluster_dir.clone(),
|
||||
server_policy,
|
||||
},
|
||||
bind: cli_bind.unwrap_or_else(|| "127.0.0.1:8080".to_string()),
|
||||
allow_unauthenticated: cli_allow_unauthenticated || env_unauth,
|
||||
require_all_graphs,
|
||||
})
|
||||
}
|
||||
|
||||
/// RFC-011 cluster-only boot: the server serves exclusively from a
|
||||
/// cluster's applied revision (`--cluster <dir | s3://…>`). The legacy
|
||||
/// omnigraph.yaml / `--target` / positional-URI single-graph boot paths
|
||||
/// were removed — a deployment serves from exactly one source.
|
||||
pub async fn load_server_settings(
|
||||
cli_cluster: Option<&PathBuf>,
|
||||
cli_bind: Option<String>,
|
||||
cli_allow_unauthenticated: bool,
|
||||
cli_require_all_graphs: bool,
|
||||
) -> Result<ServerConfig> {
|
||||
let Some(cluster_dir) = cli_cluster else {
|
||||
bail!(
|
||||
"omnigraph-server boots from a cluster: pass --cluster <dir|s3://…> \
|
||||
(the cluster's applied revision is the deployment artifact). The legacy \
|
||||
single-graph boot (positional <URI>, --target, --config omnigraph.yaml) \
|
||||
was removed in RFC-011."
|
||||
);
|
||||
};
|
||||
load_cluster_settings(
|
||||
cluster_dir,
|
||||
cli_bind,
|
||||
cli_allow_unauthenticated,
|
||||
cli_require_all_graphs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn env_flag(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
let trimmed = v.trim();
|
||||
!trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// MR-723 server runtime state, classified from the three-state matrix
|
||||
/// of (bearer tokens configured) × (policy file configured) at startup.
|
||||
///
|
||||
/// * **Open** — neither tokens nor policy; requires explicit
|
||||
/// `allow_unauthenticated`. Effectively a "trust the network" dev
|
||||
/// mode. `serve()` refuses to start in this shape without the flag,
|
||||
/// so the only way to reach this state at runtime is via deliberate
|
||||
/// operator opt-in.
|
||||
/// * **DefaultDeny** — tokens configured but no policy file. The
|
||||
/// server requires a valid bearer token; once authenticated, every
|
||||
/// action except `Read` is denied with 403. Closes the "tokens but
|
||||
/// forgot the policy file" trap.
|
||||
/// * **PolicyEnabled** — policy file configured and at least one
|
||||
/// bearer token configured. Cedar evaluates every authenticated
|
||||
/// request. Policy without tokens is rejected at startup —
|
||||
/// such a server would 401 every request, which is bug-shaped
|
||||
/// rather than feature-shaped (operators wanting "deny all
|
||||
/// unauthenticated traffic" should configure tokens plus a
|
||||
/// deny-all policy to get meaningful 403s with policy-decision
|
||||
/// logging instead).
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ServerRuntimeState {
|
||||
Open,
|
||||
DefaultDeny,
|
||||
PolicyEnabled,
|
||||
}
|
||||
|
||||
/// Compute the [`ServerRuntimeState`] from the configured inputs.
|
||||
/// Pulled out as a pure function so the matrix is unit-testable
|
||||
/// without standing up the full server.
|
||||
///
|
||||
/// The classifier is the **single source of truth** for "should we
|
||||
/// start?" — both `serve()`'s single-mode and multi-mode branches
|
||||
/// call this before constructing their `AppState`. Adding a startup
|
||||
/// invariant here means both modes enforce it automatically; the
|
||||
/// alternative (per-constructor `bail!`) drifts the moment a third
|
||||
/// mode is added.
|
||||
pub fn classify_server_runtime_state(
|
||||
has_tokens: bool,
|
||||
has_policy: bool,
|
||||
allow_unauthenticated: bool,
|
||||
) -> Result<ServerRuntimeState> {
|
||||
match (has_tokens, has_policy, allow_unauthenticated) {
|
||||
(false, false, false) => bail!(
|
||||
"server has no bearer tokens and no policy file configured. This is a fully \
|
||||
open server — pass `--unauthenticated` (or set OMNIGRAPH_UNAUTHENTICATED=1) \
|
||||
if you actually want that, otherwise configure bearer tokens (see \
|
||||
docs/user/operations/server.md) and a graph or cluster policy bundle in \
|
||||
the cluster config, then run `omnigraph cluster apply` and restart."
|
||||
),
|
||||
(false, false, true) => Ok(ServerRuntimeState::Open),
|
||||
(true, false, _) => Ok(ServerRuntimeState::DefaultDeny),
|
||||
(false, true, _) => bail!(
|
||||
"policy file is configured but no bearer tokens — every request would 401 \
|
||||
because no token can ever match. Configure at least one bearer token (see \
|
||||
docs/user/operations/server.md), or remove the policy file. To deny all unauthenticated \
|
||||
traffic deliberately, configure tokens plus a deny-all Cedar rule — that \
|
||||
produces meaningful 403s with policy-decision logging instead of silent 401s."
|
||||
),
|
||||
(true, true, _) => Ok(ServerRuntimeState::PolicyEnabled),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_bearer_token(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_bearer_actor(value: String) -> Result<String> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
bail!("bearer token actor names must not be blank");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_bearer_tokens_json(value: &str) -> Result<Vec<(String, String)>> {
|
||||
let entries: HashMap<String, String> = serde_json::from_str(value)
|
||||
.wrap_err("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON must be a JSON object of actor->token")?;
|
||||
Ok(entries.into_iter().collect())
|
||||
}
|
||||
|
||||
pub(crate) fn read_bearer_tokens_file(path: &str) -> Result<Vec<(String, String)>> {
|
||||
let contents = fs::read_to_string(path)
|
||||
.wrap_err_with(|| format!("failed to read bearer tokens file at {path}"))?;
|
||||
parse_bearer_tokens_json(&contents)
|
||||
.wrap_err_with(|| format!("failed to parse bearer tokens file at {path}"))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_bearer_tokens(
|
||||
entries: Vec<(String, String)>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let mut seen_actors = HashSet::new();
|
||||
let mut seen_tokens = HashSet::new();
|
||||
let mut normalized = Vec::with_capacity(entries.len());
|
||||
|
||||
for (actor, token) in entries {
|
||||
let actor = normalize_bearer_actor(actor)?;
|
||||
let Some(token) = normalize_bearer_token(Some(token)) else {
|
||||
bail!("bearer token for actor '{actor}' must not be blank");
|
||||
};
|
||||
if !seen_actors.insert(actor.clone()) {
|
||||
bail!("duplicate bearer token actor '{actor}'");
|
||||
}
|
||||
if !seen_tokens.insert(token.clone()) {
|
||||
bail!("duplicate bearer token value configured");
|
||||
}
|
||||
normalized.push((actor, token));
|
||||
}
|
||||
|
||||
normalized.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn server_bearer_tokens_from_env() -> Result<Vec<(String, String)>> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
if let Some(token) = normalize_bearer_token(std::env::var("OMNIGRAPH_SERVER_BEARER_TOKEN").ok())
|
||||
{
|
||||
entries.push(("default".to_string(), token));
|
||||
}
|
||||
|
||||
if let Some(path) =
|
||||
normalize_bearer_token(std::env::var("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE").ok())
|
||||
{
|
||||
entries.extend(read_bearer_tokens_file(&path)?);
|
||||
} else if let Some(json) =
|
||||
normalize_bearer_token(std::env::var("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON").ok())
|
||||
{
|
||||
entries.extend(parse_bearer_tokens_json(&json)?);
|
||||
}
|
||||
|
||||
validate_bearer_tokens(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GraphStartupConfig, ServerConfig, ServerConfigMode, ServerRuntimeState,
|
||||
classify_server_runtime_state, hash_bearer_token, normalize_bearer_token,
|
||||
parse_bearer_tokens_json, serve, server_bearer_tokens_from_env,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// `authorize` returns the allow/deny **decision** (`Authz`) and reserves
|
||||
/// `Err` for operational failures, so the invoke handler can hide a denial
|
||||
/// as 404 without also masking a 401/500. Pins each outcome.
|
||||
#[test]
|
||||
fn authorize_splits_decision_from_operational_error() {
|
||||
use super::{
|
||||
Authz, PolicyAction, PolicyCompiler, PolicyConfig, PolicyRequest, ResolvedActor,
|
||||
authorize,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn req(action: PolicyAction) -> PolicyRequest {
|
||||
PolicyRequest {
|
||||
action,
|
||||
branch: None,
|
||||
target_branch: None,
|
||||
}
|
||||
}
|
||||
let actor = ResolvedActor::cluster_static(Arc::from("act-alice"));
|
||||
|
||||
// --- No policy engine installed (open / default-deny modes) ---
|
||||
// A server-scoped action is denied in every no-policy state.
|
||||
assert!(matches!(
|
||||
authorize(Some(&actor), None, req(PolicyAction::GraphList)).unwrap(),
|
||||
Authz::Denied(_)
|
||||
));
|
||||
// Authenticated actor + a non-read per-graph action → default-deny.
|
||||
assert!(matches!(
|
||||
authorize(Some(&actor), None, req(PolicyAction::Change)).unwrap(),
|
||||
Authz::Denied(_)
|
||||
));
|
||||
// `read` is the one per-graph action permitted without a policy.
|
||||
assert!(matches!(
|
||||
authorize(Some(&actor), None, req(PolicyAction::Read)).unwrap(),
|
||||
Authz::Allowed
|
||||
));
|
||||
// Open mode (no actor, no policy) → allowed.
|
||||
assert!(matches!(
|
||||
authorize(None, None, req(PolicyAction::Read)).unwrap(),
|
||||
Authz::Allowed
|
||||
));
|
||||
|
||||
// --- Policy engine installed ---
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
"version: 1\n\
|
||||
groups:\n team: [act-alice]\n\
|
||||
rules:\n - id: team-read\n allow:\n actors: { group: team }\n actions: [read]\n branch_scope: any\n",
|
||||
)
|
||||
.unwrap();
|
||||
let engine = PolicyCompiler::compile(&policy, "graph").unwrap();
|
||||
|
||||
// A matched allow rule → Allowed.
|
||||
assert!(matches!(
|
||||
authorize(
|
||||
Some(&actor),
|
||||
Some(&engine),
|
||||
PolicyRequest {
|
||||
action: PolicyAction::Read,
|
||||
branch: Some("main".to_string()),
|
||||
target_branch: None
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
Authz::Allowed
|
||||
));
|
||||
// Known actor, no matching allow rule → Denied, carrying the decision message.
|
||||
match authorize(
|
||||
Some(&actor),
|
||||
Some(&engine),
|
||||
PolicyRequest {
|
||||
action: PolicyAction::Change,
|
||||
branch: Some("main".to_string()),
|
||||
target_branch: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
{
|
||||
Authz::Denied(message) => {
|
||||
assert!(!message.is_empty(), "a deny carries its decision message")
|
||||
}
|
||||
Authz::Allowed => panic!("change must be denied: only read is allowed"),
|
||||
}
|
||||
// Policy installed but no actor → operational failure (`Err`), NOT a
|
||||
// decision. This is the split that keeps a 401/500 from being masked
|
||||
// as the denial's response in the invoke handler.
|
||||
assert!(
|
||||
authorize(None, Some(&engine), req(PolicyAction::Read)).is_err(),
|
||||
"a missing actor with a policy installed is an operational error, not a deny"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_bearer_token_produces_32_byte_output() {
|
||||
let hash = hash_bearer_token("any-token");
|
||||
assert_eq!(hash.len(), 32);
|
||||
}
|
||||
|
||||
/// The single gate both open paths funnel through: it refuses a
|
||||
/// schema breakage (naming the graph label + query), attaches a clean
|
||||
/// registry, and collapses an empty one to `None`. Pure over its args
|
||||
/// (no engine), so it covers the multi-graph path's logic too — the
|
||||
/// only per-path difference is the `label`, asserted here.
|
||||
#[test]
|
||||
fn validate_and_attach_gates_on_schema_and_collapses_empty() {
|
||||
use crate::queries::{QueryRegistry, RegistrySpec};
|
||||
use omnigraph_compiler::catalog::build_catalog;
|
||||
use omnigraph_compiler::schema::parser::parse_schema;
|
||||
|
||||
let schema = parse_schema("node User {\nname: String\n}\n").unwrap();
|
||||
let catalog = build_catalog(&schema).unwrap();
|
||||
let spec = |name: &str, source: &str| RegistrySpec {
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
expose: false,
|
||||
tool_name: None,
|
||||
};
|
||||
|
||||
// Empty registry → nothing attached, no error.
|
||||
let empty = super::validate_and_attach(QueryRegistry::default(), &catalog, "g").unwrap();
|
||||
assert!(empty.is_none());
|
||||
|
||||
// A query that type-checks → attached.
|
||||
let ok = QueryRegistry::from_specs(vec![spec(
|
||||
"find_user",
|
||||
"query find_user() { match { $u: User } return { $u.name } }",
|
||||
)])
|
||||
.unwrap();
|
||||
assert!(
|
||||
super::validate_and_attach(ok, &catalog, "g")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// A query referencing a type the schema lacks → boot refusal that
|
||||
// names both the graph label and the offending query.
|
||||
let broken = QueryRegistry::from_specs(vec![spec(
|
||||
"ghost",
|
||||
"query ghost() { match { $w: Widget } return { $w.name } }",
|
||||
)])
|
||||
.unwrap();
|
||||
let err = super::validate_and_attach(broken, &catalog, "graph-x").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("graph-x"), "labels the graph: {msg}");
|
||||
assert!(msg.contains("ghost"), "names the query: {msg}");
|
||||
assert!(
|
||||
msg.contains("schema check"),
|
||||
"mentions the schema check: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_bearer_token_is_deterministic() {
|
||||
assert_eq!(
|
||||
hash_bearer_token("stable-input"),
|
||||
hash_bearer_token("stable-input"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_bearer_token_differs_for_different_inputs() {
|
||||
assert_ne!(hash_bearer_token("token-a"), hash_bearer_token("token-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_bearer_token_matches_known_sha256_vector() {
|
||||
// SHA-256("abc"). If this ever fails, the hash function was swapped.
|
||||
let hash = hash_bearer_token("abc");
|
||||
let hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
assert_eq!(
|
||||
hex,
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_settings_require_cluster_boot_source() {
|
||||
// RFC-011 cluster-only: with no --cluster the server refuses to
|
||||
// start and names the cluster-required remedy.
|
||||
let error = super::load_server_settings(None, None, false, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("boots from a cluster"),
|
||||
"expected cluster-required error, got: {error}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_open_requires_explicit_unauthenticated_flag() {
|
||||
// State 1: no tokens, no policy, no flag → refuse to start.
|
||||
let error = classify_server_runtime_state(false, false, false).unwrap_err();
|
||||
let msg = error.to_string();
|
||||
assert!(
|
||||
msg.contains("--unauthenticated"),
|
||||
"expected refusal message mentioning --unauthenticated, got: {msg}"
|
||||
);
|
||||
|
||||
// Same matrix cell but with the flag set → Open mode permitted.
|
||||
assert_eq!(
|
||||
classify_server_runtime_state(false, false, true).unwrap(),
|
||||
ServerRuntimeState::Open
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_tokens_without_policy_is_default_deny() {
|
||||
// State 2: tokens configured, no policy → DefaultDeny regardless
|
||||
// of the flag (the flag opts into the fully-open dev mode; it
|
||||
// doesn't downgrade default-deny back to open).
|
||||
assert_eq!(
|
||||
classify_server_runtime_state(true, false, false).unwrap(),
|
||||
ServerRuntimeState::DefaultDeny
|
||||
);
|
||||
assert_eq!(
|
||||
classify_server_runtime_state(true, false, true).unwrap(),
|
||||
ServerRuntimeState::DefaultDeny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn serve_refuses_to_start_with_policy_but_no_tokens_multi_mode() {
|
||||
// Bug 2 from the bot-review pass: multi-mode startup was missing
|
||||
// the "policy requires tokens" check that single-mode enforces.
|
||||
// After centralizing the check in `classify_server_runtime_state`,
|
||||
// both modes get the same enforcement. This test guards the
|
||||
// multi-mode propagation path.
|
||||
//
|
||||
// Sibling test below pins single mode. Together they pin that
|
||||
// the classifier is called from both branches of `serve()`.
|
||||
let _guard = EnvGuard::set(&[
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKEN", None),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE", None),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", None),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET", None),
|
||||
("OMNIGRAPH_UNAUTHENTICATED", None),
|
||||
]);
|
||||
let temp = tempdir().unwrap();
|
||||
// The classifier reads `has_policy_configured` from the config
|
||||
// shape (does the Option contain a path?), not from file
|
||||
// existence, so we can hand it a path without writing a real
|
||||
// policy file — the bail fires before policy load.
|
||||
let policy_path = temp.path().join("server-policy.yaml");
|
||||
let config = ServerConfig {
|
||||
mode: ServerConfigMode::Multi {
|
||||
graphs: vec![GraphStartupConfig {
|
||||
graph_id: "alpha".to_string(),
|
||||
uri: temp
|
||||
.path()
|
||||
.join("alpha.omni")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
policy: None,
|
||||
embedding: None,
|
||||
queries: crate::queries::QueryRegistry::default(),
|
||||
}],
|
||||
config_path: temp.path().join("omnigraph.yaml"),
|
||||
server_policy: Some(crate::PolicySource::File(policy_path)),
|
||||
},
|
||||
bind: "127.0.0.1:0".to_string(),
|
||||
allow_unauthenticated: false,
|
||||
require_all_graphs: false,
|
||||
};
|
||||
let result = serve(config).await;
|
||||
let err = result
|
||||
.expect_err("serve should refuse to start in multi mode with policy but no tokens");
|
||||
let msg = format!("{:?}", err);
|
||||
assert!(
|
||||
msg.contains("policy file is configured but no bearer tokens"),
|
||||
"expected policy-without-tokens rejection in multi mode, got: {msg}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn serve_refuses_to_start_in_state_1_without_unauthenticated() {
|
||||
// MR-723 PR A: pin the integration boundary that the classifier
|
||||
// is actually called by `serve()` before any side-effecting
|
||||
// work (Lance dataset open, TcpListener::bind). The classifier
|
||||
// itself is unit-tested above; this test guards the propagation
|
||||
// path from `classify_server_runtime_state` through serve's
|
||||
// `?` so a future refactor that drops the call returns red.
|
||||
//
|
||||
// Marked `#[serial]` because we have to clear all bearer-token
|
||||
// env vars, and another test in this module setting any of them
|
||||
// concurrently would corrupt the read inside `resolve_token_source`.
|
||||
let _guard = EnvGuard::set(&[
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKEN", None),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE", None),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", None),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET", None),
|
||||
("OMNIGRAPH_UNAUTHENTICATED", None),
|
||||
]);
|
||||
let temp = tempdir().unwrap();
|
||||
// Graph path doesn't need to exist — classifier fires before
|
||||
// any engine open.
|
||||
let config = ServerConfig {
|
||||
mode: ServerConfigMode::Multi {
|
||||
graphs: vec![GraphStartupConfig {
|
||||
graph_id: "default".to_string(),
|
||||
uri: temp
|
||||
.path()
|
||||
.join("graph.omni")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
policy: None,
|
||||
embedding: None,
|
||||
queries: crate::queries::QueryRegistry::default(),
|
||||
}],
|
||||
config_path: temp.path().join("cluster"),
|
||||
server_policy: None,
|
||||
},
|
||||
bind: "127.0.0.1:0".to_string(),
|
||||
allow_unauthenticated: false,
|
||||
require_all_graphs: false,
|
||||
};
|
||||
let result = serve(config).await;
|
||||
let err =
|
||||
result.expect_err("serve should refuse to start in State 1 without --unauthenticated");
|
||||
let msg = format!("{:?}", err);
|
||||
assert!(
|
||||
msg.contains("no bearer tokens") || msg.contains("policy file"),
|
||||
"expected refusal message naming the misconfiguration, got: {msg}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_policy_enabled_requires_tokens() {
|
||||
// State 3: tokens + policy → PolicyEnabled, regardless of the
|
||||
// `allow_unauthenticated` flag (Cedar evaluates the bearer,
|
||||
// the flag is moot once tokens exist).
|
||||
assert_eq!(
|
||||
classify_server_runtime_state(true, true, false).unwrap(),
|
||||
ServerRuntimeState::PolicyEnabled
|
||||
);
|
||||
assert_eq!(
|
||||
classify_server_runtime_state(true, true, true).unwrap(),
|
||||
ServerRuntimeState::PolicyEnabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_policy_without_tokens_is_rejected() {
|
||||
// Closes the "policy installed but no tokens → silent 401 on
|
||||
// every request" footgun. The same shape that single-mode
|
||||
// `open_with_bearer_tokens_and_policy` used to bail on
|
||||
// privately is now rejected by the classifier so both single
|
||||
// and multi mode get the same enforcement from one source of
|
||||
// truth.
|
||||
for allow_unauthenticated in [false, true] {
|
||||
let err =
|
||||
classify_server_runtime_state(false, true, allow_unauthenticated).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("policy file is configured but no bearer tokens"),
|
||||
"expected policy-without-tokens rejection message; got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("every request would 401"),
|
||||
"rejection message must name the failure mode; got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_bearer_token_trims_and_filters_blank_values() {
|
||||
assert_eq!(normalize_bearer_token(None), None);
|
||||
assert_eq!(normalize_bearer_token(Some(" ".to_string())), None);
|
||||
assert_eq!(
|
||||
normalize_bearer_token(Some(" demo-token ".to_string())).as_deref(),
|
||||
Some("demo-token")
|
||||
);
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
saved: Vec<(&'static str, Option<String>)>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(vars: &[(&'static str, Option<&str>)]) -> Self {
|
||||
let saved = vars
|
||||
.iter()
|
||||
.map(|(name, _)| (*name, env::var(name).ok()))
|
||||
.collect::<Vec<_>>();
|
||||
for (name, value) in vars {
|
||||
unsafe {
|
||||
match value {
|
||||
Some(value) => env::set_var(name, value),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { saved }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
for (name, value) in self.saved.drain(..) {
|
||||
unsafe {
|
||||
match value {
|
||||
Some(value) => env::set_var(name, value),
|
||||
None => env::remove_var(name),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bearer_tokens_json_reads_actor_token_map() {
|
||||
let tokens = parse_bearer_tokens_json(r#"{"alice":" token-a ","bob":"token-b"}"#).unwrap();
|
||||
assert_eq!(tokens.len(), 2);
|
||||
assert!(tokens.contains(&("alice".to_string(), " token-a ".to_string())));
|
||||
assert!(tokens.contains(&("bob".to_string(), "token-b".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn server_bearer_tokens_from_env_reads_legacy_token_and_token_file() {
|
||||
let temp = tempdir().unwrap();
|
||||
let tokens_path = temp.path().join("tokens.json");
|
||||
fs::write(
|
||||
&tokens_path,
|
||||
r#"{"team-01":"token-one","team-02":"token-two"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let _guard = EnvGuard::set(&[
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKEN", Some(" legacy-token ")),
|
||||
(
|
||||
"OMNIGRAPH_SERVER_BEARER_TOKENS_FILE",
|
||||
Some(tokens_path.to_str().unwrap()),
|
||||
),
|
||||
("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", None),
|
||||
]);
|
||||
|
||||
let tokens = server_bearer_tokens_from_env().unwrap();
|
||||
assert_eq!(
|
||||
tokens,
|
||||
vec![
|
||||
("default".to_string(), "legacy-token".to_string()),
|
||||
("team-01".to_string(), "token-one".to_string()),
|
||||
("team-02".to_string(), "token-two".to_string()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,919 +0,0 @@
|
|||
//! Bearer auth, actor resolution, Cedar policy decisions, admission.
|
||||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
use omnigraph::error::OmniError;
|
||||
use omnigraph::loader::LoadMode;
|
||||
use omnigraph_server::api::{
|
||||
BranchCreateRequest, BranchMergeRequest, ChangeRequest, ErrorOutput, ExportRequest, ReadRequest, SchemaApplyRequest,
|
||||
};
|
||||
use omnigraph_server::{AppState, build_app};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn healthz_succeeds_after_startup() {
|
||||
let (_temp, app) = app_for_loaded_graph().await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/healthz")
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["status"], "ok");
|
||||
assert_eq!(body["version"], env!("CARGO_PKG_VERSION"));
|
||||
match option_env!("OMNIGRAPH_SOURCE_VERSION") {
|
||||
Some(source_version) => assert_eq!(body["source_version"], source_version),
|
||||
None => assert!(body.get("source_version").is_none()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn protected_routes_require_bearer_token() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn protected_routes_accept_valid_bearer_token_while_healthz_stays_open() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
||||
|
||||
let health = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/healthz")
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(health.status(), StatusCode::OK);
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer demo-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["branches"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn protected_routes_accept_any_configured_team_bearer_token() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[
|
||||
("team-01", "token-one"),
|
||||
("team-02", "token-two"),
|
||||
])
|
||||
.await;
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-two")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(body["branches"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn bearer_token_resolves_to_correct_actor_for_policy_decisions() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(
|
||||
&policy_path,
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
readers: [act-a]
|
||||
writers: [act-b]
|
||||
protected_branches: [main]
|
||||
rules:
|
||||
- id: readers-only
|
||||
allow:
|
||||
actors: { group: readers }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![
|
||||
("act-a".to_string(), "token-a".to_string()),
|
||||
("act-b".to_string(), "token-b".to_string()),
|
||||
],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
// act-a is authenticated AND authorized.
|
||||
let (ok_status, _) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ok_status, StatusCode::OK);
|
||||
|
||||
// act-b is authenticated but policy rejects — proves the resolved actor
|
||||
// (not some default) was the policy subject.
|
||||
let (denied_status, denied_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-b")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let denied_error: ErrorOutput = serde_json::from_value(denied_body).unwrap();
|
||||
assert_eq!(denied_status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
denied_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
||||
);
|
||||
|
||||
// Unknown token: 401, never reaches the policy engine.
|
||||
let (bad_status, _) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer wrong-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(bad_status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
// Same readers/writers split as
|
||||
// `bearer_token_resolves_to_correct_actor_for_policy_decisions` —
|
||||
// `act-a` can read main, `act-b` cannot. The asymmetry is what
|
||||
// makes the spoof-up/spoof-down distinction observable.
|
||||
fs::write(
|
||||
&policy_path,
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
readers: [act-a]
|
||||
writers: [act-b]
|
||||
protected_branches: [main]
|
||||
rules:
|
||||
- id: readers-only
|
||||
allow:
|
||||
actors: { group: readers }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![
|
||||
("act-a".to_string(), "token-a".to_string()),
|
||||
("act-b".to_string(), "token-b".to_string()),
|
||||
],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
// (1) Spoof-up: bearer for act-b (denied) + X-Actor-Id: act-a (allowed).
|
||||
// If the server were trusting the header, this would succeed as
|
||||
// act-a. The contract is: the bearer wins. Expect 403 because
|
||||
// act-b can't read.
|
||||
let (spoof_up_status, spoof_up_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-b")
|
||||
.header("x-actor-id", "act-a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let spoof_up_error: ErrorOutput = serde_json::from_value(spoof_up_body).unwrap();
|
||||
assert_eq!(
|
||||
spoof_up_status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"X-Actor-Id must not promote a denied bearer to an allowed actor",
|
||||
);
|
||||
assert_eq!(
|
||||
spoof_up_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden),
|
||||
);
|
||||
|
||||
// (2) Spoof-down: bearer for act-a (allowed) + X-Actor-Id: act-b (denied).
|
||||
// If the server were trusting the header, this would fail as act-b.
|
||||
// The contract is: the bearer wins. Expect 200 because act-a can read.
|
||||
let (spoof_down_status, _) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-a")
|
||||
.header("x-actor-id", "act-b")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
spoof_down_status,
|
||||
StatusCode::OK,
|
||||
"X-Actor-Id must not demote an allowed bearer to a denied actor",
|
||||
);
|
||||
|
||||
// (3) Empty-string spoof attempt: an X-Actor-Id of "" must not
|
||||
// leak through as the policy subject. Same expectation as (1):
|
||||
// bearer for act-b is denied regardless of what the header tries.
|
||||
let (empty_spoof_status, _) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-b")
|
||||
.header("x-actor-id", "")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
empty_spoof_status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"empty X-Actor-Id must not clear the resolved actor",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_allows_read_but_distinguishes_401_from_403() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
|
||||
&[("act-bruno", "team-token"), ("act-ragnor", "admin-token")],
|
||||
POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (missing_status, missing_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap();
|
||||
assert_eq!(missing_status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
missing_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
||||
);
|
||||
|
||||
let (snapshot_status, snapshot_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(snapshot_status, StatusCode::OK);
|
||||
assert_eq!(snapshot_body["branch"], "main");
|
||||
|
||||
let export_request = ExportRequest {
|
||||
branch: Some("main".to_string()),
|
||||
type_names: Vec::new(),
|
||||
table_keys: Vec::new(),
|
||||
};
|
||||
let (forbidden_status, forbidden_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/export"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&export_request).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let forbidden_error: ErrorOutput = serde_json::from_value(forbidden_body).unwrap();
|
||||
assert_eq!(forbidden_status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
forbidden_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
||||
);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(g("/export"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&export_request).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_uses_resolved_branch_for_snapshot_reads() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let snapshot_id = {
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
db.resolve_snapshot("main").await.unwrap().to_string()
|
||||
};
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(&policy_path, POLICY_PROTECTED_READ_YAML).unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![("act-bruno".to_string(), "team-token".to_string())],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let read = ReadRequest {
|
||||
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
||||
query_name: Some("get_person".to_string()),
|
||||
params: Some(json!({ "name": "Alice" })),
|
||||
branch: None,
|
||||
snapshot: Some(snapshot_id),
|
||||
};
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/read"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(body["target"]["branch"], Value::Null);
|
||||
assert_eq!(
|
||||
body["target"]["snapshot"].as_str(),
|
||||
read.snapshot.as_deref()
|
||||
);
|
||||
assert_eq!(body["row_count"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_blocks_change_on_protected_main_but_allows_unprotected_branch() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
||||
.await
|
||||
.unwrap();
|
||||
drop(db);
|
||||
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(&policy_path, POLICY_YAML).unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![("act-bruno".to_string(), "team-token".to_string())],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let main_change = ChangeRequest {
|
||||
query: MUTATION_QUERIES.to_string(),
|
||||
name: Some("insert_person".to_string()),
|
||||
params: Some(json!({ "name": "Mina", "age": 28 })),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
let (main_status, main_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/change"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&main_change).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let main_error: ErrorOutput = serde_json::from_value(main_body).unwrap();
|
||||
assert_eq!(main_status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
main_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
||||
);
|
||||
|
||||
let feature_change = ChangeRequest {
|
||||
query: MUTATION_QUERIES.to_string(),
|
||||
name: Some("insert_person".to_string()),
|
||||
params: Some(json!({ "name": "Mina", "age": 28 })),
|
||||
branch: Some("feature".to_string()),
|
||||
};
|
||||
let (feature_status, feature_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/change"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&feature_change).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(feature_status, StatusCode::OK);
|
||||
assert_eq!(feature_body["branch"], "feature");
|
||||
assert_eq!(feature_body["affected_nodes"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_blocks_non_admin_merge_to_main_and_allows_admin() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
db.branch_create_from(ReadTarget::branch("main"), "feature")
|
||||
.await
|
||||
.unwrap();
|
||||
db.load(
|
||||
"feature",
|
||||
r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#,
|
||||
LoadMode::Append,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
drop(db);
|
||||
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(&policy_path, POLICY_YAML).unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![
|
||||
("act-bruno".to_string(), "team-token".to_string()),
|
||||
("act-ragnor".to_string(), "admin-token".to_string()),
|
||||
],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let merge = BranchMergeRequest {
|
||||
source: "feature".to_string(),
|
||||
target: Some("main".to_string()),
|
||||
};
|
||||
let (deny_status, deny_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches/merge"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let deny_error: ErrorOutput = serde_json::from_value(deny_body).unwrap();
|
||||
assert_eq!(deny_status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
deny_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
||||
);
|
||||
|
||||
let (allow_status, allow_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches/merge"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(allow_status, StatusCode::OK);
|
||||
assert_eq!(allow_body["actor_id"], "act-ragnor");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authenticated_change_stamps_actor_on_commits() {
|
||||
// With the Run state machine removed, actor_id is recorded
|
||||
// directly on the commit graph (no intermediate run record).
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[("act-andrew", "token-one")]).await;
|
||||
|
||||
let change = ChangeRequest {
|
||||
query: MUTATION_QUERIES.to_string(),
|
||||
name: Some("insert_person".to_string()),
|
||||
params: Some(json!({ "name": "Mina", "age": 28 })),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
let (change_status, change_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/change"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer token-one")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(change_status, StatusCode::OK);
|
||||
assert_eq!(change_body["actor_id"], "act-andrew");
|
||||
|
||||
let (commits_status, commits_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/commits?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-one")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(commits_status, StatusCode::OK);
|
||||
let head = commits_body["commits"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.expect("head commit should exist");
|
||||
assert_eq!(head["actor_id"], "act-andrew");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authenticated_branch_merge_stamps_merge_actor_on_head_commit() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth_tokens(&[
|
||||
("act-andrew", "token-one"),
|
||||
("act-ragnor", "token-two"),
|
||||
])
|
||||
.await;
|
||||
|
||||
let create = BranchCreateRequest {
|
||||
from: Some("main".to_string()),
|
||||
name: "feature".to_string(),
|
||||
};
|
||||
let (create_status, _) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer token-one")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&create).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(create_status, StatusCode::OK);
|
||||
|
||||
let change = ChangeRequest {
|
||||
query: MUTATION_QUERIES.to_string(),
|
||||
name: Some("insert_person".to_string()),
|
||||
params: Some(json!({ "name": "Zoe", "age": 33 })),
|
||||
branch: Some("feature".to_string()),
|
||||
};
|
||||
let (change_status, _) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/change"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer token-one")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(change_status, StatusCode::OK);
|
||||
|
||||
let merge = BranchMergeRequest {
|
||||
source: "feature".to_string(),
|
||||
target: Some("main".to_string()),
|
||||
};
|
||||
let (merge_status, merge_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/branches/merge"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer token-two")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&merge).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(merge_status, StatusCode::OK);
|
||||
assert_eq!(merge_body["actor_id"], "act-ragnor");
|
||||
|
||||
let (commit_status, commit_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/commits?branch=main"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer token-two")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(commit_status, StatusCode::OK);
|
||||
let head = commit_body["commits"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.expect("head commit should exist");
|
||||
assert_eq!(head["actor_id"], "act-ragnor");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn engine_layer_policy_fires_via_direct_arc_omnigraph_from_new_single() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
|
||||
// Permit `act-allowed` for change actions; `act-blocked` is not in
|
||||
// any allowed group — every change request from them must deny.
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(&policy_path, permit_all_policy_yaml(&["act-allowed"])).unwrap();
|
||||
let policy_engine =
|
||||
omnigraph_server::PolicyEngine::load_graph(&policy_path, graph.to_string_lossy().as_ref())
|
||||
.unwrap();
|
||||
|
||||
let workload = omnigraph_server::workload::WorkloadController::new(100, 1_000_000_000);
|
||||
let state = AppState::new_single(
|
||||
graph.to_string_lossy().to_string(),
|
||||
db,
|
||||
vec![("act-blocked".to_string(), "block-token".to_string())],
|
||||
Some(policy_engine),
|
||||
workload,
|
||||
);
|
||||
|
||||
// Reach into the routing and pull the engine the same way an
|
||||
// embedded consumer holding `Arc<Omnigraph>` would. If `new_single`
|
||||
// failed to apply `with_policy` to the engine, this `mutate_as`
|
||||
// would succeed — the HTTP-layer is bypassed entirely.
|
||||
// RFC-011 cluster-only: the single-graph convenience constructor
|
||||
// registers the graph under the reserved id `default`.
|
||||
let key = omnigraph_server::GraphKey::cluster(
|
||||
omnigraph_server::GraphId::try_from("default").unwrap(),
|
||||
);
|
||||
let handle = match state.routing().registry.get(&key) {
|
||||
omnigraph_server::RegistryLookup::Ready(handle) => handle,
|
||||
omnigraph_server::RegistryLookup::Gone => panic!("default graph must be registered"),
|
||||
};
|
||||
let engine = Arc::clone(&handle.engine);
|
||||
|
||||
let mut params: omnigraph_compiler::ParamMap = Default::default();
|
||||
params.insert(
|
||||
"name".to_string(),
|
||||
omnigraph_compiler::Literal::String("EngineLayerBlocked".to_string()),
|
||||
);
|
||||
params.insert("age".to_string(), omnigraph_compiler::Literal::Integer(30));
|
||||
let result = engine
|
||||
.mutate_as(
|
||||
"main",
|
||||
MUTATION_QUERIES,
|
||||
"insert_person",
|
||||
¶ms,
|
||||
Some("act-blocked"),
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Err(OmniError::Policy(_)) => { /* expected — engine-layer gate fired */ }
|
||||
Ok(_) => panic!(
|
||||
"engine-layer policy did NOT fire — act-blocked successfully ran mutate_as via \
|
||||
the engine pulled from the registry handle. AppState::new_single failed to apply \
|
||||
with_policy to the underlying Omnigraph engine. This is the B2 footgun the \
|
||||
with_policy_engine deletion was supposed to close."
|
||||
),
|
||||
Err(other) => panic!("expected OmniError::Policy, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn oversized_request_body_returns_payload_too_large() {
|
||||
let (_temp, app) = app_for_loaded_graph().await;
|
||||
let oversized = "x".repeat(1_100_000);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(g("/read"))
|
||||
.method(Method::POST)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(oversized))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn default_deny_mode_allows_read_for_authenticated_actor() {
|
||||
let (_temp, app) = app_for_graph_with_auth_tokens_only(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-andrew", "demo-token")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, _body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot"))
|
||||
.method(Method::GET)
|
||||
.header(AUTHORIZATION, "Bearer demo-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn default_deny_mode_rejects_change_with_forbidden() {
|
||||
let (_temp, app) = app_for_graph_with_auth_tokens_only(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-andrew", "demo-token")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let change = ChangeRequest {
|
||||
query: MUTATION_QUERIES.to_string(),
|
||||
name: Some("insert_person".to_string()),
|
||||
params: Some(json!({ "name": "DefaultDeny", "age": 1 })),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/change"))
|
||||
.method(Method::POST)
|
||||
.header(AUTHORIZATION, "Bearer demo-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert!(
|
||||
error.error.contains("default-deny"),
|
||||
"expected default-deny in error message, got: {}",
|
||||
error.error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn default_deny_mode_rejects_schema_apply_with_forbidden() {
|
||||
let (_temp, app) = app_for_graph_with_auth_tokens_only(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-andrew", "demo-token")],
|
||||
)
|
||||
.await;
|
||||
|
||||
let req = SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
};
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/schema/apply"))
|
||||
.method(Method::POST)
|
||||
.header(AUTHORIZATION, "Bearer demo-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&req).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert!(
|
||||
error.error.contains("default-deny"),
|
||||
"expected default-deny in error message, got: {}",
|
||||
error.error
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_decision_parity_change_admin_on_main_allowed() {
|
||||
// (act-ragnor, change, main) — admins-change-anywhere rule applies.
|
||||
// Both SDK and HTTP must allow. Each path uses its own fresh graph
|
||||
// because allow→side-effects.
|
||||
let (_t1, graph1, policy1) = build_parity_graph().await;
|
||||
let sdk = sdk_change_decision(&graph1, &policy1, "act-ragnor").await;
|
||||
let (_t2, graph2, policy2) = build_parity_graph().await;
|
||||
let http = http_change_decision(&graph2, &policy2, "act-ragnor", "ragnor-token").await;
|
||||
assert!(
|
||||
matches!(sdk, ParityDecision::Allow) && matches!(http, ParityDecision::Allow),
|
||||
"SDK={sdk:?} HTTP={http:?} — should both Allow",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_decision_parity_change_team_on_main_denied() {
|
||||
// (act-bruno, change, main) — no rule grants bruno change on
|
||||
// protected. Both SDK and HTTP must deny. Same graph is reusable
|
||||
// because deny→no side-effects.
|
||||
let (_temp, graph, policy) = build_parity_graph().await;
|
||||
let sdk = sdk_change_decision(&graph, &policy, "act-bruno").await;
|
||||
let http = http_change_decision(&graph, &policy, "act-bruno", "bruno-token").await;
|
||||
assert!(
|
||||
matches!(sdk, ParityDecision::Deny) && matches!(http, ParityDecision::Deny),
|
||||
"SDK={sdk:?} HTTP={http:?} — should both Deny",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_decision_parity_branch_merge_admin_allowed() {
|
||||
// (act-ragnor, branch_merge, feature→main) — admins-merge-to-protected
|
||||
// rule applies. Both Allow. Each path uses its own fresh graph —
|
||||
// a successful merge consumes the feature branch's commit on main.
|
||||
let (_t1, graph1, policy1) = build_parity_graph().await;
|
||||
let sdk = sdk_merge_decision(&graph1, &policy1, "act-ragnor").await;
|
||||
let (_t2, graph2, policy2) = build_parity_graph().await;
|
||||
let http = http_merge_decision(&graph2, &policy2, "act-ragnor", "ragnor-token").await;
|
||||
assert!(
|
||||
matches!(sdk, ParityDecision::Allow) && matches!(http, ParityDecision::Allow),
|
||||
"SDK={sdk:?} HTTP={http:?} — should both Allow",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn policy_decision_parity_branch_merge_team_denied() {
|
||||
// (act-bruno, branch_merge, feature→main) — no rule grants bruno
|
||||
// branch_merge. Both Deny.
|
||||
let (_temp, graph, policy) = build_parity_graph().await;
|
||||
let sdk = sdk_merge_decision(&graph, &policy, "act-bruno").await;
|
||||
let http = http_merge_decision(&graph, &policy, "act-bruno", "bruno-token").await;
|
||||
assert!(
|
||||
matches!(sdk, ParityDecision::Deny) && matches!(http, ParityDecision::Deny),
|
||||
"SDK={sdk:?} HTTP={http:?} — should both Deny",
|
||||
);
|
||||
}
|
||||
|
|
@ -1,562 +0,0 @@
|
|||
//! Server settings loading and mode inference (single vs multi).
|
||||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use omnigraph::db::Omnigraph;
|
||||
use omnigraph_server::{AppState, build_app};
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
mod multi_graph_startup {
|
||||
use super::*;
|
||||
use omnigraph::storage::normalize_root_uri;
|
||||
use omnigraph_server::{GraphHandle, GraphId, GraphKey, GraphRegistry, InsertError};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn build_multi_mode_app(graph_ids: &[&str]) -> (Vec<tempfile::TempDir>, Router) {
|
||||
let mut dirs = Vec::with_capacity(graph_ids.len());
|
||||
let mut handles = Vec::with_capacity(graph_ids.len());
|
||||
for id in graph_ids {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let graph_uri = dir.path().join(id).to_str().unwrap().to_string();
|
||||
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
||||
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
||||
handles.push(Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from(*id).unwrap()),
|
||||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
}));
|
||||
dirs.push(dir);
|
||||
}
|
||||
let workload = omnigraph_server::workload::WorkloadController::from_env();
|
||||
let state = AppState::new_multi(handles, Vec::new(), None, workload, None).unwrap();
|
||||
let app = build_app(state);
|
||||
(dirs, app)
|
||||
}
|
||||
|
||||
/// Cluster route `/graphs/{graph_id}/snapshot` resolves to the right
|
||||
/// engine. Two graphs side by side; assert each responds to its own
|
||||
/// id and does NOT respond to the other's URL.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cluster_routes_dispatch_per_graph_handle() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["alpha", "beta"]).await;
|
||||
for id in ["alpha", "beta"] {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri(format!("/graphs/{id}/snapshot?branch=main"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"graph '{id}' must respond OK on its cluster snapshot route"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown graph id under the cluster prefix yields 404 (not 500,
|
||||
/// not 410 — `Gone` is reserved for the future DELETE flow).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cluster_route_for_unknown_graph_returns_404() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs/nonexistent/snapshot?branch=main")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Coverage net for cluster-route regressions across every
|
||||
/// protected handler — not just the few that have inner path
|
||||
/// params. Bug-1 surfaced because only `/snapshot` was being
|
||||
/// exercised in cluster mode, leaving the other six protected
|
||||
/// routes implicitly untested. This sweep hits each one and
|
||||
/// asserts the response shows the handler was reached: no 404
|
||||
/// (router didn't match), no 500 with "Wrong number of path
|
||||
/// arguments" (path extractor broke), no 500 with "missing
|
||||
/// extension" (routing middleware didn't inject the handle).
|
||||
///
|
||||
/// Status codes are negative assertions because each handler's
|
||||
/// happy-path inputs differ — what matters is "the request
|
||||
/// reached the handler," not "the handler returned 200." The
|
||||
/// individual handlers' logic is already tested in single mode.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn all_protected_cluster_routes_resolve_to_their_handler() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
||||
|
||||
// (method, path, body) — one minimal request per protected
|
||||
// cluster route. Bodies are valid enough that the router and
|
||||
// extractors succeed; whether the engine ultimately returns
|
||||
// 200 or 4xx is per-handler and not what this test pins.
|
||||
let cases: &[(Method, &str, Option<&str>)] = &[
|
||||
(Method::GET, "/graphs/alpha/snapshot?branch=main", None),
|
||||
(Method::GET, "/graphs/alpha/schema", None),
|
||||
(Method::GET, "/graphs/alpha/branches", None),
|
||||
(Method::GET, "/graphs/alpha/commits", None),
|
||||
(
|
||||
Method::POST,
|
||||
"/graphs/alpha/read",
|
||||
Some(r#"{"query_source":"query q() { return {} }"}"#),
|
||||
),
|
||||
(
|
||||
Method::POST,
|
||||
"/graphs/alpha/change",
|
||||
Some(r#"{"query_source":"query q() { return {} }"}"#),
|
||||
),
|
||||
(
|
||||
Method::POST,
|
||||
"/graphs/alpha/export",
|
||||
Some(r#"{"branch":"main"}"#),
|
||||
),
|
||||
(
|
||||
Method::POST,
|
||||
"/graphs/alpha/schema/apply",
|
||||
Some(r#"{"schema_source":"","allow_data_loss":false}"#),
|
||||
),
|
||||
(Method::POST, "/graphs/alpha/ingest", Some(r#"{"data":""}"#)),
|
||||
(
|
||||
Method::POST,
|
||||
"/graphs/alpha/branches/merge",
|
||||
Some(r#"{"source":"main","target":"main"}"#),
|
||||
),
|
||||
];
|
||||
|
||||
for (method, path, body) in cases {
|
||||
let req_body = body
|
||||
.map(|s| Body::from(s.to_string()))
|
||||
.unwrap_or_else(Body::empty);
|
||||
let req = Request::builder()
|
||||
.method(method.clone())
|
||||
.uri(*path)
|
||||
.header("content-type", "application/json")
|
||||
.body(req_body)
|
||||
.unwrap();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let body_str = String::from_utf8_lossy(&bytes);
|
||||
|
||||
assert_ne!(
|
||||
status,
|
||||
StatusCode::NOT_FOUND,
|
||||
"{} {} — router didn't match (cluster-route mounting regression). Body: {}",
|
||||
method,
|
||||
path,
|
||||
body_str,
|
||||
);
|
||||
assert!(
|
||||
!(status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
&& body_str.contains("Wrong number of path arguments")),
|
||||
"{} {} — path extractor broke (Bug-1 class regression). Body: {}",
|
||||
method,
|
||||
path,
|
||||
body_str,
|
||||
);
|
||||
assert!(
|
||||
!(status == StatusCode::INTERNAL_SERVER_ERROR
|
||||
&& body_str.to_lowercase().contains("missing extension")),
|
||||
"{} {} — routing middleware didn't inject GraphHandle. Body: {}",
|
||||
method,
|
||||
path,
|
||||
body_str,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression for the bot-surfaced path-extractor bug: cluster
|
||||
/// routes whose inner path also captures a parameter
|
||||
/// (`/graphs/{graph_id}/branches/{branch}`,
|
||||
/// `/graphs/{graph_id}/commits/{commit_id}`) must extract the
|
||||
/// inner param cleanly. Axum 0.8 propagates the outer `{graph_id}`
|
||||
/// capture into nested handlers, so a `Path<String>` extractor
|
||||
/// would see two values and fail with "Wrong number of path
|
||||
/// arguments. Expected 1 but got 2." Today both DELETE branch and
|
||||
/// GET commit-by-id break in multi-mode because their handlers
|
||||
/// use bare `Path<String>` — this test pins the fix.
|
||||
///
|
||||
/// The broader `all_protected_cluster_routes_resolve_to_their_handler`
|
||||
/// test sweeps the full route surface; this one stays narrowly
|
||||
/// targeted at the inner-path-param shape because that's the
|
||||
/// specific regression class.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn cluster_routes_with_inner_path_params_deserialize_correctly() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
||||
|
||||
// Create a branch we can then delete — DELETE /graphs/alpha/branches/feature
|
||||
let create_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/graphs/alpha/branches")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"name":"feature"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
create_resp.status(),
|
||||
StatusCode::OK,
|
||||
"branch create on the cluster route must succeed before delete can be tested"
|
||||
);
|
||||
|
||||
// DELETE /graphs/{graph_id}/branches/{branch} — exercises a handler
|
||||
// whose only Path extractor (`branch`) is inside a nested route
|
||||
// that also captures `graph_id`. The handler must pick `branch`
|
||||
// by name, not by position.
|
||||
let delete_resp = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.uri("/graphs/alpha/branches/feature")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let delete_status = delete_resp.status();
|
||||
let delete_body = to_bytes(delete_resp.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
delete_status,
|
||||
StatusCode::OK,
|
||||
"DELETE /graphs/{{id}}/branches/{{branch}} must extract `branch` cleanly. \
|
||||
Body: {}",
|
||||
String::from_utf8_lossy(&delete_body),
|
||||
);
|
||||
|
||||
// GET /graphs/{graph_id}/commits/{commit_id} — same shape: the
|
||||
// handler's only Path extractor is the inner `commit_id`, which
|
||||
// must deserialize by name even though `graph_id` is also in scope.
|
||||
// We don't know a real commit_id, but the failure mode under test
|
||||
// is path extraction, not commit lookup — a 404 from the engine
|
||||
// is fine; a 500 with "Wrong number of path arguments" is the bug.
|
||||
let commit_resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs/alpha/commits/0000000000000000")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let commit_status = commit_resp.status();
|
||||
let commit_body = to_bytes(commit_resp.into_body(), usize::MAX).await.unwrap();
|
||||
let body_str = String::from_utf8_lossy(&commit_body);
|
||||
assert!(
|
||||
commit_status != StatusCode::INTERNAL_SERVER_ERROR
|
||||
|| !body_str.contains("Wrong number of path arguments"),
|
||||
"GET /graphs/{{id}}/commits/{{commit_id}} must extract `commit_id` cleanly. \
|
||||
Got: {} | {}",
|
||||
commit_status,
|
||||
body_str,
|
||||
);
|
||||
}
|
||||
|
||||
/// RFC-011 cluster-only: flat per-graph routes never resolve — the
|
||||
/// router only mounts under `/graphs/{graph_id}/...` so a root
|
||||
/// `/snapshot` returns 404.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn flat_routes_404_at_root() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/snapshot?branch=main")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registry_rejects_duplicate_normalized_graph_uris() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let graph_uri = dir.path().join("same").to_str().unwrap().to_string();
|
||||
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
||||
let engine = Arc::new(Omnigraph::init(&graph_uri, &schema).await.unwrap());
|
||||
|
||||
let alpha = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()),
|
||||
uri: graph_uri.clone(),
|
||||
engine: Arc::clone(&engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let beta = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("beta").unwrap()),
|
||||
uri: format!("file://{graph_uri}/"),
|
||||
engine,
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
|
||||
match GraphRegistry::from_handles(vec![alpha, beta]) {
|
||||
Err(InsertError::DuplicateUri(uri)) => {
|
||||
assert!(
|
||||
normalize_root_uri(&uri).is_ok(),
|
||||
"duplicate URI should still be parseable, got {uri}"
|
||||
);
|
||||
}
|
||||
Err(err) => panic!("expected DuplicateUri for normalized aliases, got {err:?}"),
|
||||
Ok(_) => panic!("expected DuplicateUri for normalized aliases, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registry_stores_canonical_graph_uri() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let graph_uri = dir.path().join("canonical").to_str().unwrap().to_string();
|
||||
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
||||
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()),
|
||||
uri: format!("file://{graph_uri}/"),
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
|
||||
let registry = GraphRegistry::from_handles(vec![handle]).unwrap();
|
||||
let listed = registry.list();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].uri, graph_uri);
|
||||
}
|
||||
|
||||
/// `GET /graphs` must NOT leak the registry in Open mode without
|
||||
/// an explicit server policy. Operators who pass `--unauthenticated`
|
||||
/// opted into trusting the network for graph DATA, not for leaking
|
||||
/// server topology (graph IDs + URIs, which may contain S3 bucket
|
||||
/// paths or internal hostnames). Cedar gating the management
|
||||
/// surface is the documented contract for `server_graphs_list`
|
||||
/// ("don't leak the registry until the operator explicitly
|
||||
/// authorizes it"); enforcing that contract in every runtime
|
||||
/// state — not just `PolicyEnabled` — is the correct-by-design
|
||||
/// closure of the open-mode hole the bot-review pass surfaced.
|
||||
///
|
||||
/// Today (pre-fix) this returns 200 because `authorize_request`'s
|
||||
/// no-policy fallback only denies when `actor.is_some()`, so Open
|
||||
/// mode (`actor: None`) falls through to `Ok(())`. The fix in the
|
||||
/// next commit tightens the fallback so server-scoped actions
|
||||
/// always require explicit policy.
|
||||
///
|
||||
/// Sort-order coverage previously lived here; it has moved to
|
||||
/// `get_graphs_with_server_policy_authorizes_per_cedar` where
|
||||
/// the response body is now non-empty and operator-authorized.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_graphs_denied_in_open_mode_without_server_policy() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["beta", "alpha"]).await;
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = resp.status();
|
||||
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let body_str = String::from_utf8_lossy(&body);
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"GET /graphs must require an explicit server policy in every \
|
||||
runtime state; Open-mode bypass would leak server topology. \
|
||||
Body: {body_str}",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// `GET /graphs` requires bearer auth when tokens are configured.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_graphs_requires_bearer_auth_when_configured() {
|
||||
use omnigraph_server::{GraphHandle, GraphId, GraphKey};
|
||||
// Build a multi-mode app with bearer tokens configured.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let graph_uri = dir.path().join("alpha").to_str().unwrap().to_string();
|
||||
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
||||
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()),
|
||||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let tokens = vec![("act-andrew".to_string(), "secret-token".to_string())];
|
||||
let workload = omnigraph_server::workload::WorkloadController::from_env();
|
||||
let state = AppState::new_multi(vec![handle], tokens, None, workload, None).unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
// No Authorization header → 401.
|
||||
let resp_no_auth = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp_no_auth.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
// With auth but no server policy → 403 (default-deny, since
|
||||
// GraphList is not Read).
|
||||
let resp_authed = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs")
|
||||
.header("authorization", "Bearer secret-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp_authed.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// `GET /graphs` with a server policy that allows `graph_list` → 200
|
||||
/// and returns the registry sorted alphabetically by `graph_id`.
|
||||
/// `GET /graphs` with a server policy that does NOT allow
|
||||
/// `graph_list` (viewer group) → 403.
|
||||
///
|
||||
/// This test owns the alphabetical-sort coverage that previously
|
||||
/// lived in `get_graphs_lists_registered_graphs_in_multi_mode`.
|
||||
/// That test now asserts denial in Open mode (server-scoped actions
|
||||
/// require explicit policy in every runtime state), so the positive
|
||||
/// body-shape assertions need a home where the response is
|
||||
/// operator-authorized — here.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_graphs_with_server_policy_authorizes_per_cedar() {
|
||||
use omnigraph_policy::PolicyEngine;
|
||||
use omnigraph_server::{GraphHandle, GraphId, GraphKey};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Two graphs deliberately registered in non-alphabetical order
|
||||
// so the test would fail if the handler relied on insertion
|
||||
// order instead of server-side sorting.
|
||||
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
||||
let mut handles = Vec::new();
|
||||
for id in ["beta", "alpha"] {
|
||||
let graph_uri = dir.path().join(id).to_str().unwrap().to_string();
|
||||
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
||||
handles.push(Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from(id).unwrap()),
|
||||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
}));
|
||||
}
|
||||
|
||||
// Server policy: admins can graph_list, viewers cannot.
|
||||
let policy_path = dir.path().join("server-policy.yaml");
|
||||
fs::write(
|
||||
&policy_path,
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
admins: [act-andrew]
|
||||
viewers: [act-bruno]
|
||||
rules:
|
||||
- id: admins-list-graphs
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [graph_list]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let server_policy = PolicyEngine::load_server(&policy_path).unwrap();
|
||||
|
||||
let tokens = vec![
|
||||
("act-andrew".to_string(), "andrew-token".to_string()),
|
||||
("act-bruno".to_string(), "bruno-token".to_string()),
|
||||
];
|
||||
let workload = omnigraph_server::workload::WorkloadController::from_env();
|
||||
let state =
|
||||
AppState::new_multi(handles, tokens, Some(server_policy), workload, None).unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
// Admin → 200, body returns both graphs alphabetically sorted.
|
||||
let resp_admin = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs")
|
||||
.header("authorization", "Bearer andrew-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp_admin.status(),
|
||||
StatusCode::OK,
|
||||
"admin must be allowed graph_list"
|
||||
);
|
||||
let body = to_bytes(resp_admin.into_body(), usize::MAX).await.unwrap();
|
||||
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||
let graphs = json["graphs"].as_array().unwrap();
|
||||
assert_eq!(graphs.len(), 2, "response must list both registered graphs");
|
||||
assert_eq!(
|
||||
graphs[0]["graph_id"].as_str().unwrap(),
|
||||
"alpha",
|
||||
"server must sort graphs alphabetically by graph_id (insertion order was 'beta', 'alpha')"
|
||||
);
|
||||
assert_eq!(graphs[1]["graph_id"].as_str().unwrap(), "beta");
|
||||
|
||||
// Viewer → 403
|
||||
let resp_viewer = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs")
|
||||
.header("authorization", "Bearer bruno-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp_viewer.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"viewer must be denied graph_list (Cedar gate)"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,865 +0,0 @@
|
|||
//! Cluster-mode boot and the concurrent branch-ops matrix.
|
||||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use omnigraph::db::Omnigraph;
|
||||
use omnigraph::loader::{LoadMode, load_jsonl};
|
||||
use omnigraph_server::api::{ErrorOutput, ReadRequest};
|
||||
use omnigraph_server::{AppState, build_app};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use tower::ServiceExt;
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_branch_ops_morphological_matrix() {
|
||||
// Cell a: Merge × Merge, distinct targets.
|
||||
// Pre-fix on b09a097/22d76db: branch_merge_impl's swap-restore race
|
||||
// landed feature_a's content in target_b instead of target_a (and
|
||||
// vice versa — symmetric swap). Identity asserts catch both
|
||||
// asymmetric and symmetric variants.
|
||||
{
|
||||
let cell = "a:merge×merge:distinct-targets";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "feature-a-cella").await;
|
||||
h.insert_person("feature-a-cella", "EveA-cella", 22).await;
|
||||
h.create_branch("main", "feature-b-cella").await;
|
||||
h.insert_person("feature-b-cella", "FrankB-cella", 33).await;
|
||||
h.create_branch("main", "target-a-cella").await;
|
||||
h.create_branch("main", "target-b-cella").await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_merge("feature-a-cella".to_string(), "target-a-cella".to_string()),
|
||||
matrix::op_merge("feature-b-cella".to_string(), "target-b-cella".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] merge a", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] merge b", cell);
|
||||
h.assert_persons("target-a-cella", cell, &["EveA-cella"], &["FrankB-cella"])
|
||||
.await;
|
||||
h.assert_persons("target-b-cella", cell, &["FrankB-cella"], &["EveA-cella"])
|
||||
.await;
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cella").await;
|
||||
}
|
||||
|
||||
// Cell b: Merge × Merge, same target / distinct sources.
|
||||
// Both want to land in main. merge_exclusive serializes; both should
|
||||
// succeed and main should contain BOTH sources' contributions.
|
||||
{
|
||||
let cell = "b:merge×merge:same-target-distinct-sources";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "src-x-cellb").await;
|
||||
h.insert_person("src-x-cellb", "Xavier-cellb", 41).await;
|
||||
h.create_branch("main", "src-y-cellb").await;
|
||||
h.insert_person("src-y-cellb", "Yvonne-cellb", 42).await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_merge("src-x-cellb".to_string(), "main".to_string()),
|
||||
matrix::op_merge("src-y-cellb".to_string(), "main".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] merge x", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] merge y", cell);
|
||||
h.assert_persons("main", cell, &["Xavier-cellb", "Yvonne-cellb"], &[])
|
||||
.await;
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cellb").await;
|
||||
}
|
||||
|
||||
// Cell c: Merge × Merge, same source / distinct targets (fanout).
|
||||
// One source merged into two targets simultaneously. merge_exclusive
|
||||
// serializes; both targets should reflect the source's content.
|
||||
{
|
||||
let cell = "c:merge×merge:same-source-distinct-targets";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "src-shared-cellc").await;
|
||||
h.insert_person("src-shared-cellc", "Sharon-cellc", 50)
|
||||
.await;
|
||||
h.create_branch("main", "tgt-1-cellc").await;
|
||||
h.create_branch("main", "tgt-2-cellc").await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_merge("src-shared-cellc".to_string(), "tgt-1-cellc".to_string()),
|
||||
matrix::op_merge("src-shared-cellc".to_string(), "tgt-2-cellc".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] merge into tgt-1", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] merge into tgt-2", cell);
|
||||
h.assert_persons("tgt-1-cellc", cell, &["Sharon-cellc"], &[])
|
||||
.await;
|
||||
h.assert_persons("tgt-2-cellc", cell, &["Sharon-cellc"], &[])
|
||||
.await;
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cellc").await;
|
||||
}
|
||||
|
||||
// Cell d: Merge × Change, both touching main. C2 permits both
|
||||
// succeed, or exactly one clean 409 if the merge detects target
|
||||
// movement after planning but before acquiring the queue.
|
||||
{
|
||||
let cell = "d:merge×change:into-target";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "feature-celld").await;
|
||||
h.insert_person("feature-celld", "EveD-celld", 22).await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_merge("feature-celld".to_string(), "main".to_string()),
|
||||
matrix::op_change_insert("main".to_string(), "FrankD-celld".to_string(), 33),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
||||
assert!(
|
||||
sa.status == StatusCode::OK || sa.status == StatusCode::CONFLICT,
|
||||
"[{}] merge must be 200 or clean 409, got {}",
|
||||
cell,
|
||||
sa.status
|
||||
);
|
||||
if sa.status == StatusCode::OK {
|
||||
h.assert_persons("main", cell, &["EveD-celld", "FrankD-celld"], &[])
|
||||
.await;
|
||||
} else {
|
||||
let error: ErrorOutput = serde_json::from_slice(&sa.body).unwrap();
|
||||
let conflict = error
|
||||
.manifest_conflict
|
||||
.expect("merge 409 must include manifest_conflict");
|
||||
assert_eq!(
|
||||
conflict.table_key, "node:Person",
|
||||
"[{}] conflict table",
|
||||
cell
|
||||
);
|
||||
h.assert_persons("main", cell, &["FrankD-celld"], &["EveD-celld"])
|
||||
.await;
|
||||
}
|
||||
h.assert_post_op_sentinel(cell, "sentinel-celld").await;
|
||||
}
|
||||
|
||||
// Cell e: Merge × BranchCreateFrom-target. Concurrent fork off the
|
||||
// merge target while the merge runs. Both should succeed; the new
|
||||
// branch should have a coherent view (either pre- or post-merge,
|
||||
// both valid). After both, target = main has the merged content.
|
||||
{
|
||||
let cell = "e:merge×branch_create_from:target";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "src-celle").await;
|
||||
h.insert_person("src-celle", "Eve-celle", 22).await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_merge("src-celle".to_string(), "main".to_string()),
|
||||
matrix::op_branch_create("main".to_string(), "fork-celle".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] merge", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] branch_create_from", cell);
|
||||
// Main definitely has Eve.
|
||||
h.assert_persons("main", cell, &["Eve-celle"], &[]).await;
|
||||
// fork-celle was forked off main at SOME version; main's current
|
||||
// count is 5 (4 seeded + Eve). fork-celle has either 4 (pre-merge
|
||||
// snapshot) or 5 (post-merge snapshot); both are valid timings.
|
||||
let fork_count = h.person_count("fork-celle").await;
|
||||
assert!(
|
||||
fork_count == 4 || fork_count == 5,
|
||||
"[{}] fork-celle row count must be pre- or post-merge view (4 or 5), got {}",
|
||||
cell,
|
||||
fork_count
|
||||
);
|
||||
h.assert_post_op_sentinel(cell, "sentinel-celle").await;
|
||||
}
|
||||
|
||||
// Cell f: BranchCreateFrom × BranchCreateFrom, distinct parents.
|
||||
// Pre-fix on f925ad1: swap-restore race in branch_create_from_impl
|
||||
// forked the new branch off the wrong parent. Identity asserts pin
|
||||
// that fork-from-A inherits A's content, fork-from-B inherits B's.
|
||||
{
|
||||
let cell = "f:branch_create_from×branch_create_from:distinct-parents";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "alpha-cellf").await;
|
||||
h.insert_person("alpha-cellf", "Eve-cellf", 22).await;
|
||||
h.create_branch("main", "beta-cellf").await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_branch_create("alpha-cellf".to_string(), "gamma-cellf".to_string()),
|
||||
matrix::op_branch_create("beta-cellf".to_string(), "delta-cellf".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] gamma create", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] delta create", cell);
|
||||
// gamma forks off alpha → must contain Eve.
|
||||
h.assert_persons("gamma-cellf", cell, &["Eve-cellf"], &[])
|
||||
.await;
|
||||
// delta forks off beta → must NOT contain Eve.
|
||||
h.assert_persons("delta-cellf", cell, &[], &["Eve-cellf"])
|
||||
.await;
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cellf").await;
|
||||
}
|
||||
|
||||
// Cell g: BranchCreateFrom × BranchDelete, unrelated branches.
|
||||
// Disjoint branches; both should complete cleanly without
|
||||
// interference.
|
||||
{
|
||||
let cell = "g:branch_create_from×branch_delete:unrelated";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "doomed-cellg").await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_branch_create("main".to_string(), "newborn-cellg".to_string()),
|
||||
matrix::op_branch_delete("doomed-cellg".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] create newborn", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] delete doomed", cell);
|
||||
// newborn-cellg exists with main's content.
|
||||
h.assert_persons("newborn-cellg", cell, &["Alice"], &[])
|
||||
.await;
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cellg").await;
|
||||
}
|
||||
|
||||
// Cell h: BranchDelete × BranchDelete, distinct branches. Both call
|
||||
// refresh() internally; verify no deadlock and both deletes land.
|
||||
{
|
||||
let cell = "h:branch_delete×branch_delete:distinct";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "doomed1-cellh").await;
|
||||
h.create_branch("main", "doomed2-cellh").await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_branch_delete("doomed1-cellh".to_string()),
|
||||
matrix::op_branch_delete("doomed2-cellh".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] delete 1", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] delete 2", cell);
|
||||
// Verify both gone via /branches list (snapshot would still work
|
||||
// for a deleted branch via parent fallback in some paths, so we
|
||||
// use the explicit list).
|
||||
let r = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(g("/branches"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::OK);
|
||||
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
|
||||
let list_body: Value = serde_json::from_slice(&body).unwrap();
|
||||
let branches: Vec<&str> = list_body["branches"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
!branches.contains(&"doomed1-cellh"),
|
||||
"[{}] doomed1 still in branch list: {:?}",
|
||||
cell,
|
||||
branches
|
||||
);
|
||||
assert!(
|
||||
!branches.contains(&"doomed2-cellh"),
|
||||
"[{}] doomed2 still in branch list: {:?}",
|
||||
cell,
|
||||
branches
|
||||
);
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cellh").await;
|
||||
}
|
||||
|
||||
// Cell i: BranchDelete × Change, on a different branch. Delete one
|
||||
// branch while a /change runs on main. Both should succeed.
|
||||
{
|
||||
let cell = "i:branch_delete×change:distinct-branch";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "doomed-celli").await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_branch_delete("doomed-celli".to_string()),
|
||||
matrix::op_change_insert("main".to_string(), "Pat-celli".to_string(), 44),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] delete", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
||||
h.assert_persons("main", cell, &["Pat-celli"], &[]).await;
|
||||
h.assert_post_op_sentinel(cell, "sentinel-celli").await;
|
||||
}
|
||||
|
||||
// Cell j: BranchCreateFrom × Change, both on main. The fork timing
|
||||
// determines whether the new branch sees the change (pre or post).
|
||||
// Both valid. Main must contain the inserted row.
|
||||
{
|
||||
let cell = "j:branch_create_from×change:on-source";
|
||||
let h = matrix::Harness::new().await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_branch_create("main".to_string(), "twin-cellj".to_string()),
|
||||
matrix::op_change_insert("main".to_string(), "Quincy-cellj".to_string(), 55),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sa.status, StatusCode::OK, "[{}] branch_create", cell);
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
||||
h.assert_persons("main", cell, &["Quincy-cellj"], &[]).await;
|
||||
// twin-cellj has either pre-change view (no Quincy) or
|
||||
// post-change view (with Quincy); either is valid.
|
||||
let twin_has_quincy = h.person_exists("twin-cellj", "Quincy-cellj").await;
|
||||
let _ = twin_has_quincy; // either valid timing — just ensure no panic
|
||||
h.assert_post_op_sentinel(cell, "sentinel-cellj").await;
|
||||
}
|
||||
|
||||
// Cell k: reopen consistency. Run a representative concurrent pair,
|
||||
// drop the engine, reopen on a separate handle, verify state matches.
|
||||
{
|
||||
let cell = "k:reopen-after-pair";
|
||||
let h = matrix::Harness::new().await;
|
||||
h.create_branch("main", "src-cellk").await;
|
||||
h.insert_person("src-cellk", "Rita-cellk", 36).await;
|
||||
|
||||
let (sa, sb) = h
|
||||
.run_pair(
|
||||
matrix::op_merge("src-cellk".to_string(), "main".to_string()),
|
||||
matrix::op_change_insert("main".to_string(), "Steve-cellk".to_string(), 37),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(sb.status, StatusCode::OK, "[{}] change", cell);
|
||||
assert!(
|
||||
sa.status == StatusCode::OK || sa.status == StatusCode::CONFLICT,
|
||||
"[{}] merge must be 200 or clean 409, got {}",
|
||||
cell,
|
||||
sa.status
|
||||
);
|
||||
if sa.status == StatusCode::OK {
|
||||
h.assert_persons("main", cell, &["Rita-cellk", "Steve-cellk"], &[])
|
||||
.await;
|
||||
} else {
|
||||
let error: ErrorOutput = serde_json::from_slice(&sa.body).unwrap();
|
||||
let conflict = error
|
||||
.manifest_conflict
|
||||
.expect("merge 409 must include manifest_conflict");
|
||||
assert_eq!(
|
||||
conflict.table_key, "node:Person",
|
||||
"[{}] conflict table",
|
||||
cell
|
||||
);
|
||||
h.assert_persons("main", cell, &["Steve-cellk"], &["Rita-cellk"])
|
||||
.await;
|
||||
}
|
||||
|
||||
// Reopen via a fresh AppState on the same graph.
|
||||
let graph_uri = format!("{}/server.omni", h._temp.path().display());
|
||||
let reopened = AppState::open(graph_uri.clone()).await.unwrap();
|
||||
let app2 = build_app(reopened);
|
||||
// Sanity: the same identity check via the new app must see
|
||||
// Rita and Steve.
|
||||
let r = app2
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::OK, "[{}] reopen snapshot", cell);
|
||||
let body = to_bytes(r.into_body(), usize::MAX).await.unwrap();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
let person_rows = v["tables"]
|
||||
.as_array()
|
||||
.and_then(|tables| {
|
||||
tables
|
||||
.iter()
|
||||
.find(|t| t["table_key"].as_str() == Some("node:Person"))
|
||||
})
|
||||
.and_then(|t| t["row_count"].as_u64())
|
||||
.expect("reopen snapshot must include node:Person row_count");
|
||||
let expected_rows = if sa.status == StatusCode::OK { 6 } else { 5 };
|
||||
assert_eq!(
|
||||
person_rows, expected_rows,
|
||||
"[{}] reopened main should include seed (4) + committed concurrent writes",
|
||||
cell,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_boot_serves_applied_state() {
|
||||
let temp = converged_cluster_dir("").await;
|
||||
let settings = cluster_settings(temp.path()).await.unwrap();
|
||||
let omnigraph_server::ServerConfigMode::Multi {
|
||||
graphs,
|
||||
config_path,
|
||||
server_policy,
|
||||
} = settings.mode
|
||||
else {
|
||||
panic!("cluster boot must select multi-graph routing");
|
||||
};
|
||||
assert_eq!(graphs.len(), 1);
|
||||
assert_eq!(graphs[0].graph_id, "knowledge");
|
||||
assert!(server_policy.is_none());
|
||||
|
||||
let state =
|
||||
omnigraph_server::open_multi_graph_state(graphs, Vec::new(), None, config_path, false)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
// The management surface keeps its closed-by-default contract: without a
|
||||
// cluster-scoped policy bundle there is no server-level Cedar engine, so
|
||||
// GET /graphs refuses even in cluster mode.
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/graphs")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/graphs/knowledge/queries")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert!(
|
||||
body["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|q| q["name"] == "find_person"),
|
||||
"{body}"
|
||||
);
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/graphs/knowledge/queries/find_person")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"params":{"name":"nobody"}}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_boot_quarantines_graph_open_failures() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let schema = "\nnode Person {\n name: String @key\n}\n";
|
||||
let good_uri = temp.path().join("good.omni");
|
||||
Omnigraph::init(good_uri.to_string_lossy().as_ref(), schema)
|
||||
.await
|
||||
.unwrap();
|
||||
let bad_uri = temp.path().join("missing.omni");
|
||||
let server_policy = omnigraph_server::PolicySource::Inline(
|
||||
r#"
|
||||
version: 1
|
||||
kind: server
|
||||
groups:
|
||||
admins: [act-admin]
|
||||
rules:
|
||||
- id: admins-list-graphs
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [graph_list]
|
||||
"#
|
||||
.to_string(),
|
||||
);
|
||||
let graphs = vec![
|
||||
omnigraph_server::GraphStartupConfig {
|
||||
graph_id: "broken".to_string(),
|
||||
uri: bad_uri.to_string_lossy().to_string(),
|
||||
policy: None,
|
||||
embedding: None,
|
||||
queries: stored_query_registry(&[]),
|
||||
},
|
||||
omnigraph_server::GraphStartupConfig {
|
||||
graph_id: "good".to_string(),
|
||||
uri: good_uri.to_string_lossy().to_string(),
|
||||
policy: None,
|
||||
embedding: None,
|
||||
queries: stored_query_registry(&[]),
|
||||
},
|
||||
];
|
||||
let strict_err = match omnigraph_server::open_multi_graph_state(
|
||||
graphs.clone(),
|
||||
vec![("act-admin".to_string(), "admin-token".to_string())],
|
||||
Some(&server_policy),
|
||||
temp.path().join("cluster.yaml"),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("strict startup should reject a failed graph open"),
|
||||
Err(err) => err,
|
||||
};
|
||||
assert!(
|
||||
strict_err
|
||||
.to_string()
|
||||
.contains("strict multi-graph startup requires every graph to open"),
|
||||
"{strict_err}"
|
||||
);
|
||||
let state = omnigraph_server::open_multi_graph_state(
|
||||
graphs,
|
||||
vec![("act-admin".to_string(), "admin-token".to_string())],
|
||||
Some(&server_policy),
|
||||
temp.path().join("cluster.yaml"),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ready: Vec<_> = state
|
||||
.routing()
|
||||
.registry
|
||||
.list()
|
||||
.iter()
|
||||
.map(|handle| handle.key.graph_id.as_str().to_string())
|
||||
.collect();
|
||||
ready.sort();
|
||||
assert_eq!(ready, vec!["good"]);
|
||||
let app = build_app(state);
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/graphs")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(
|
||||
body["graphs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|graph| graph["graph_id"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["good"]
|
||||
);
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/graphs/broken/queries")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND, "{body}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn cluster_boot_injects_embedding_provider_config() {
|
||||
const EMBED_SCHEMA: &str = r#"
|
||||
node Doc {
|
||||
slug: String @key
|
||||
title: String @index
|
||||
embedding: Vector(4) @embed("title", model="cluster-mock") @index
|
||||
}
|
||||
"#;
|
||||
const EMBED_QUERY: &str = r#"
|
||||
query vector_search_string($q: String) {
|
||||
match { $d: Doc }
|
||||
return { $d.slug, $d.title }
|
||||
order { nearest($d.embedding, $q) }
|
||||
limit 3
|
||||
}
|
||||
"#;
|
||||
|
||||
let alpha = mock_embedding("alpha", 4);
|
||||
let beta = mock_embedding("beta", 4);
|
||||
let gamma = mock_embedding("gamma", 4);
|
||||
let data = format!(
|
||||
concat!(
|
||||
r#"{{"type":"Doc","data":{{"slug":"alpha-doc","title":"alpha guide","embedding":[{}]}}}}"#,
|
||||
"\n",
|
||||
r#"{{"type":"Doc","data":{{"slug":"beta-doc","title":"beta guide","embedding":[{}]}}}}"#,
|
||||
"\n",
|
||||
r#"{{"type":"Doc","data":{{"slug":"gamma-doc","title":"gamma handbook","embedding":[{}]}}}}"#
|
||||
),
|
||||
format_vector(&alpha),
|
||||
format_vector(&beta),
|
||||
format_vector(&gamma),
|
||||
);
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(temp.path().join("docs.pg"), EMBED_SCHEMA).unwrap();
|
||||
fs::write(temp.path().join("search.gq"), EMBED_QUERY).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
providers:
|
||||
embedding:
|
||||
default:
|
||||
kind: mock
|
||||
model: cluster-mock
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./docs.pg
|
||||
embedding_provider: default
|
||||
queries:
|
||||
vector_search_string:
|
||||
file: ./search.gq
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let import = omnigraph_cluster::import_config_dir(temp.path()).await;
|
||||
assert!(import.ok, "{:?}", import.diagnostics);
|
||||
let apply = omnigraph_cluster::apply_config_dir(temp.path()).await;
|
||||
assert!(apply.ok && apply.converged, "{:?}", apply.diagnostics);
|
||||
|
||||
let graph_uri = temp
|
||||
.path()
|
||||
.join("graphs/knowledge.omni")
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let mut db = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
load_jsonl(&mut db, &data, LoadMode::Overwrite)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _guard = EnvGuard::set(&[
|
||||
("OMNIGRAPH_EMBEDDINGS_MOCK", None),
|
||||
("OMNIGRAPH_EMBED_PROVIDER", None),
|
||||
("OMNIGRAPH_EMBED_BASE_URL", None),
|
||||
("OMNIGRAPH_EMBED_MODEL", None),
|
||||
("OPENROUTER_API_KEY", None),
|
||||
("OPENAI_API_KEY", None),
|
||||
("GEMINI_API_KEY", None),
|
||||
]);
|
||||
let settings = cluster_settings(temp.path()).await.unwrap();
|
||||
let omnigraph_server::ServerConfigMode::Multi {
|
||||
graphs,
|
||||
config_path,
|
||||
server_policy,
|
||||
} = settings.mode
|
||||
else {
|
||||
panic!("cluster boot must select multi-graph routing");
|
||||
};
|
||||
let state = omnigraph_server::open_multi_graph_state(
|
||||
graphs,
|
||||
Vec::new(),
|
||||
server_policy.as_ref(),
|
||||
config_path,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let read = ReadRequest {
|
||||
query_source: EMBED_QUERY.to_string(),
|
||||
query_name: Some("vector_search_string".to_string()),
|
||||
params: Some(serde_json::json!({ "q": "alpha" })),
|
||||
branch: Some("main".to_string()),
|
||||
snapshot: None,
|
||||
};
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri("/graphs/knowledge/read")
|
||||
.method(Method::POST)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(body["row_count"], 3);
|
||||
assert_eq!(body["rows"][0]["d.slug"], "alpha-doc");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn cluster_boot_refuses_missing_embedding_secret_env() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("people.pg"),
|
||||
"\nnode Person {\n name: String @key\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("people.gq"),
|
||||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("cluster.yaml"),
|
||||
r#"
|
||||
version: 1
|
||||
providers:
|
||||
embedding:
|
||||
default:
|
||||
kind: openai-compatible
|
||||
api_key: ${OG_TEST_MISSING_EMBED_KEY}
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
embedding_provider: default
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let import = omnigraph_cluster::import_config_dir(temp.path()).await;
|
||||
assert!(import.ok, "{:?}", import.diagnostics);
|
||||
let apply = omnigraph_cluster::apply_config_dir(temp.path()).await;
|
||||
assert!(apply.ok && apply.converged, "{:?}", apply.diagnostics);
|
||||
|
||||
let _guard = EnvGuard::set(&[
|
||||
("OG_TEST_MISSING_EMBED_KEY", None),
|
||||
("OMNIGRAPH_EMBEDDINGS_MOCK", None),
|
||||
]);
|
||||
let err = cluster_settings(temp.path()).await.unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("embedding provider for graph 'knowledge'"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("OG_TEST_MISSING_EMBED_KEY"), "{message}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_boot_wires_policy_bindings_into_cedar_slots() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
drop(temp);
|
||||
let policy_block = r#"policies:
|
||||
graph_rules:
|
||||
file: ./graph.policy.yaml
|
||||
applies_to: [knowledge]
|
||||
cluster_rules:
|
||||
file: ./cluster.policy.yaml
|
||||
applies_to: [cluster]
|
||||
"#;
|
||||
let temp = {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("people.pg"),
|
||||
"\nnode Person {\n name: String @key\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("people.gq"),
|
||||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("graph.policy.yaml"),
|
||||
permit_all_policy_yaml(&["default"]),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("cluster.policy.yaml"),
|
||||
permit_all_policy_yaml(&["default"]).replace(
|
||||
"protected_branches: [main]\n",
|
||||
"protected_branches: [main]\nkind: server\n",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("cluster.yaml"),
|
||||
format!(
|
||||
r#"
|
||||
version: 1
|
||||
graphs:
|
||||
knowledge:
|
||||
schema: ./people.pg
|
||||
queries:
|
||||
find_person:
|
||||
file: ./people.gq
|
||||
{policy_block}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let import = omnigraph_cluster::import_config_dir(temp.path()).await;
|
||||
assert!(import.ok, "{:?}", import.diagnostics);
|
||||
let apply = omnigraph_cluster::apply_config_dir(temp.path()).await;
|
||||
assert!(apply.ok && apply.converged, "{:?}", apply.diagnostics);
|
||||
temp
|
||||
};
|
||||
|
||||
let settings = cluster_settings(temp.path()).await.unwrap();
|
||||
let omnigraph_server::ServerConfigMode::Multi {
|
||||
graphs,
|
||||
server_policy,
|
||||
..
|
||||
} = settings.mode
|
||||
else {
|
||||
panic!("cluster boot must select multi-graph routing");
|
||||
};
|
||||
// Cluster boots carry policy CONTENT (digest-verified catalog blobs),
|
||||
// not paths — the catalog may live on object storage.
|
||||
let omnigraph_server::PolicySource::Inline(graph_policy) =
|
||||
graphs[0].policy.as_ref().expect("graph-bound bundle")
|
||||
else {
|
||||
panic!("cluster-mode graph policy must be inline content");
|
||||
};
|
||||
assert!(graph_policy.contains("actors:"), "{graph_policy:?}");
|
||||
let omnigraph_server::PolicySource::Inline(server_policy) =
|
||||
server_policy.expect("cluster-bound bundle")
|
||||
else {
|
||||
panic!("cluster-mode server policy must be inline content");
|
||||
};
|
||||
assert!(server_policy.contains("kind: server"), "{server_policy:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cluster_boot_refusals() {
|
||||
// RFC-011 cluster-only: with no --cluster, boot refuses with the
|
||||
// cluster-required remedy.
|
||||
let err = omnigraph_server::load_server_settings(None, None, true, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("boots from a cluster"), "{err}");
|
||||
|
||||
let temp = converged_cluster_dir("").await;
|
||||
let dir = temp.path().to_path_buf();
|
||||
|
||||
// Tampered catalog blob refuses boot with the remedy.
|
||||
let blob_dir = dir.join("__cluster/resources/query/knowledge/find_person");
|
||||
let blob = fs::read_dir(&blob_dir)
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
fs::write(&blob, "tampered").unwrap();
|
||||
let err = cluster_settings(&dir).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("catalog_payload_digest_mismatch"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.to_string().contains("cluster refresh"), "{err}");
|
||||
|
||||
// Missing state refuses with the import/apply remedy.
|
||||
let empty = tempfile::tempdir().unwrap();
|
||||
let err = cluster_settings(empty.path()).await.unwrap_err();
|
||||
assert!(err.to_string().contains("cluster_state_missing"), "{err}");
|
||||
}
|
||||
|
|
@ -8,9 +8,10 @@ use axum::body::{Body, to_bytes};
|
|||
use axum::http::{Method, Request, StatusCode};
|
||||
use omnigraph::db::Omnigraph;
|
||||
use omnigraph::loader::{LoadMode, load_jsonl};
|
||||
use omnigraph_server::{AppState, build_app, served_openapi};
|
||||
use omnigraph_server::{ApiDoc, AppState, build_app};
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
use utoipa::OpenApi;
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
|
|
@ -70,10 +71,7 @@ async fn json_response(app: &Router, request: Request<Body>) -> (StatusCode, Val
|
|||
}
|
||||
|
||||
fn openapi_doc() -> utoipa::openapi::OpenApi {
|
||||
// RFC-011 cluster-only: the canonical committed spec is the SERVED
|
||||
// shape — protected routes nested under `/graphs/{graph_id}/…`,
|
||||
// `/healthz` and `/graphs` flat. This matches what the server serves.
|
||||
served_openapi()
|
||||
ApiDoc::openapi()
|
||||
}
|
||||
|
||||
fn openapi_json() -> Value {
|
||||
|
|
@ -161,28 +159,23 @@ fn openapi_info_contains_version() {
|
|||
// Path coverage tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The canonical served spec keeps `/healthz` and `/graphs` flat; every
|
||||
// protected route nests under `/graphs/{graph_id}/…`.
|
||||
const EXPECTED_PATHS: &[&str] = &[
|
||||
"/healthz",
|
||||
"/graphs",
|
||||
"/graphs/{graph_id}/snapshot",
|
||||
"/graphs/{graph_id}/read",
|
||||
"/graphs/{graph_id}/query",
|
||||
"/graphs/{graph_id}/export",
|
||||
"/graphs/{graph_id}/change",
|
||||
"/graphs/{graph_id}/mutate",
|
||||
"/graphs/{graph_id}/queries",
|
||||
"/graphs/{graph_id}/queries/{name}",
|
||||
"/graphs/{graph_id}/schema",
|
||||
"/graphs/{graph_id}/schema/apply",
|
||||
"/graphs/{graph_id}/load",
|
||||
"/graphs/{graph_id}/ingest",
|
||||
"/graphs/{graph_id}/branches",
|
||||
"/graphs/{graph_id}/branches/{branch}",
|
||||
"/graphs/{graph_id}/branches/merge",
|
||||
"/graphs/{graph_id}/commits",
|
||||
"/graphs/{graph_id}/commits/{commit_id}",
|
||||
"/snapshot",
|
||||
"/read",
|
||||
"/query",
|
||||
"/export",
|
||||
"/change",
|
||||
"/mutate",
|
||||
"/schema",
|
||||
"/schema/apply",
|
||||
"/ingest",
|
||||
"/branches",
|
||||
"/branches/{branch}",
|
||||
"/branches/merge",
|
||||
"/commits",
|
||||
"/commits/{commit_id}",
|
||||
];
|
||||
|
||||
#[test]
|
||||
|
|
@ -226,25 +219,25 @@ fn openapi_healthz_is_get() {
|
|||
#[test]
|
||||
fn openapi_read_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/read"]["post"].is_object());
|
||||
assert!(doc["paths"]["/read"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_export_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/export"]["post"].is_object());
|
||||
assert!(doc["paths"]["/export"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_change_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/change"]["post"].is_object());
|
||||
assert!(doc["paths"]["/change"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_mutate_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/mutate"]["post"].is_object());
|
||||
assert!(doc["paths"]["/mutate"]["post"].is_object());
|
||||
}
|
||||
|
||||
// Deprecation flagging — `/read` and `/change` are kept indefinitely for
|
||||
|
|
@ -257,7 +250,7 @@ fn openapi_mutate_is_post() {
|
|||
fn openapi_read_is_deprecated() {
|
||||
let doc = openapi_json();
|
||||
assert_eq!(
|
||||
doc["paths"]["/graphs/{graph_id}/read"]["post"]["deprecated"],
|
||||
doc["paths"]["/read"]["post"]["deprecated"],
|
||||
serde_json::Value::Bool(true),
|
||||
"/read must be flagged deprecated in OpenAPI; use /query instead"
|
||||
);
|
||||
|
|
@ -267,7 +260,7 @@ fn openapi_read_is_deprecated() {
|
|||
fn openapi_change_is_deprecated() {
|
||||
let doc = openapi_json();
|
||||
assert_eq!(
|
||||
doc["paths"]["/graphs/{graph_id}/change"]["post"]["deprecated"],
|
||||
doc["paths"]["/change"]["post"]["deprecated"],
|
||||
serde_json::Value::Bool(true),
|
||||
"/change must be flagged deprecated in OpenAPI; use /mutate instead"
|
||||
);
|
||||
|
|
@ -276,7 +269,7 @@ fn openapi_change_is_deprecated() {
|
|||
#[test]
|
||||
fn openapi_query_is_not_deprecated() {
|
||||
let doc = openapi_json();
|
||||
let deprecated = doc["paths"]["/graphs/{graph_id}/query"]["post"]
|
||||
let deprecated = doc["paths"]["/query"]["post"]
|
||||
.get("deprecated")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
|
@ -289,7 +282,7 @@ fn openapi_query_is_not_deprecated() {
|
|||
#[test]
|
||||
fn openapi_mutate_is_not_deprecated() {
|
||||
let doc = openapi_json();
|
||||
let deprecated = doc["paths"]["/graphs/{graph_id}/mutate"]["post"]
|
||||
let deprecated = doc["paths"]["/mutate"]["post"]
|
||||
.get("deprecated")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
|
@ -302,64 +295,38 @@ fn openapi_mutate_is_not_deprecated() {
|
|||
#[test]
|
||||
fn openapi_ingest_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/ingest"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_load_is_not_deprecated() {
|
||||
// RFC-009 Phase 5: /load is the canonical bulk-load endpoint.
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/load"]["post"].is_object());
|
||||
let deprecated = doc["paths"]["/graphs/{graph_id}/load"]["post"]
|
||||
.get("deprecated")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
assert!(
|
||||
!deprecated,
|
||||
"/load is the canonical load endpoint and must not be deprecated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_ingest_is_deprecated() {
|
||||
// RFC-009 Phase 5: /ingest is now the deprecated alias of /load.
|
||||
let doc = openapi_json();
|
||||
assert_eq!(
|
||||
doc["paths"]["/graphs/{graph_id}/ingest"]["post"]["deprecated"],
|
||||
serde_json::Value::Bool(true),
|
||||
"/ingest must be flagged deprecated now that /load is canonical"
|
||||
);
|
||||
assert!(doc["paths"]["/ingest"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_branches_supports_get_and_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/branches"]["get"].is_object());
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/branches"]["post"].is_object());
|
||||
assert!(doc["paths"]["/branches"]["get"].is_object());
|
||||
assert!(doc["paths"]["/branches"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_branch_delete_is_delete() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/branches/{branch}"]["delete"].is_object());
|
||||
assert!(doc["paths"]["/branches/{branch}"]["delete"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_branch_merge_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/branches/merge"]["post"].is_object());
|
||||
assert!(doc["paths"]["/branches/merge"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_commits_is_get() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/commits"]["get"].is_object());
|
||||
assert!(doc["paths"]["/commits"]["get"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_commit_show_is_get() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/commits/{commit_id}"]["get"].is_object());
|
||||
assert!(doc["paths"]["/commits/{commit_id}"]["get"].is_object());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -514,13 +481,13 @@ fn query_request_query_is_required() {
|
|||
#[test]
|
||||
fn openapi_query_is_post() {
|
||||
let doc = openapi_json();
|
||||
assert!(doc["paths"]["/graphs/{graph_id}/query"]["post"].is_object());
|
||||
assert!(doc["paths"]["/query"]["post"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_endpoint_documents_mutation_400() {
|
||||
let doc = openapi_json();
|
||||
let four_hundred = &doc["paths"]["/graphs/{graph_id}/query"]["post"]["responses"]["400"];
|
||||
let four_hundred = &doc["paths"]["/query"]["post"]["responses"]["400"];
|
||||
let description = four_hundred["description"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
description.contains("mutations") || description.contains("POST /mutate"),
|
||||
|
|
@ -731,21 +698,18 @@ fn openapi_defines_bearer_token_security_scheme() {
|
|||
fn protected_endpoints_reference_bearer_token_security() {
|
||||
let doc = openapi_json();
|
||||
let protected_paths = [
|
||||
("/graphs/{graph_id}/read", "post"),
|
||||
("/graphs/{graph_id}/change", "post"),
|
||||
("/graphs/{graph_id}/schema/apply", "post"),
|
||||
("/graphs/{graph_id}/queries", "get"),
|
||||
("/graphs/{graph_id}/queries/{name}", "post"),
|
||||
("/graphs/{graph_id}/load", "post"),
|
||||
("/graphs/{graph_id}/ingest", "post"),
|
||||
("/graphs/{graph_id}/export", "post"),
|
||||
("/graphs/{graph_id}/snapshot", "get"),
|
||||
("/graphs/{graph_id}/branches", "get"),
|
||||
("/graphs/{graph_id}/branches", "post"),
|
||||
("/graphs/{graph_id}/branches/{branch}", "delete"),
|
||||
("/graphs/{graph_id}/branches/merge", "post"),
|
||||
("/graphs/{graph_id}/commits", "get"),
|
||||
("/graphs/{graph_id}/commits/{commit_id}", "get"),
|
||||
("/read", "post"),
|
||||
("/change", "post"),
|
||||
("/schema/apply", "post"),
|
||||
("/ingest", "post"),
|
||||
("/export", "post"),
|
||||
("/snapshot", "get"),
|
||||
("/branches", "get"),
|
||||
("/branches", "post"),
|
||||
("/branches/{branch}", "delete"),
|
||||
("/branches/merge", "post"),
|
||||
("/commits", "get"),
|
||||
("/commits/{commit_id}", "get"),
|
||||
];
|
||||
|
||||
for (path, method) in protected_paths {
|
||||
|
|
@ -777,7 +741,7 @@ fn healthz_does_not_require_security() {
|
|||
#[test]
|
||||
fn branch_delete_has_branch_path_parameter() {
|
||||
let doc = openapi_json();
|
||||
let params = doc["paths"]["/graphs/{graph_id}/branches/{branch}"]["delete"]["parameters"]
|
||||
let params = doc["paths"]["/branches/{branch}"]["delete"]["parameters"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
let has_branch = params
|
||||
|
|
@ -792,7 +756,7 @@ fn branch_delete_has_branch_path_parameter() {
|
|||
#[test]
|
||||
fn commit_show_has_commit_id_path_parameter() {
|
||||
let doc = openapi_json();
|
||||
let params = doc["paths"]["/graphs/{graph_id}/commits/{commit_id}"]["get"]["parameters"]
|
||||
let params = doc["paths"]["/commits/{commit_id}"]["get"]["parameters"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
let has_commit_id = params
|
||||
|
|
@ -807,7 +771,7 @@ fn commit_show_has_commit_id_path_parameter() {
|
|||
#[test]
|
||||
fn snapshot_has_branch_query_parameter() {
|
||||
let doc = openapi_json();
|
||||
let params = doc["paths"]["/graphs/{graph_id}/snapshot"]["get"]["parameters"]
|
||||
let params = doc["paths"]["/snapshot"]["get"]["parameters"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
let has_branch = params
|
||||
|
|
@ -822,7 +786,7 @@ fn snapshot_has_branch_query_parameter() {
|
|||
#[test]
|
||||
fn commits_has_branch_query_parameter() {
|
||||
let doc = openapi_json();
|
||||
let params = doc["paths"]["/graphs/{graph_id}/commits"]["get"]["parameters"]
|
||||
let params = doc["paths"]["/commits"]["get"]["parameters"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
let has_branch = params
|
||||
|
|
@ -862,7 +826,7 @@ fn openapi_operations_have_tags() {
|
|||
#[test]
|
||||
fn read_endpoint_200_references_read_output_schema() {
|
||||
let doc = openapi_json();
|
||||
let content = &doc["paths"]["/graphs/{graph_id}/read"]["post"]["responses"]["200"]["content"];
|
||||
let content = &doc["paths"]["/read"]["post"]["responses"]["200"]["content"];
|
||||
let schema = &content["application/json"]["schema"];
|
||||
let ref_path = schema["$ref"].as_str().unwrap();
|
||||
assert!(
|
||||
|
|
@ -874,7 +838,7 @@ fn read_endpoint_200_references_read_output_schema() {
|
|||
#[test]
|
||||
fn change_endpoint_200_references_change_output_schema() {
|
||||
let doc = openapi_json();
|
||||
let content = &doc["paths"]["/graphs/{graph_id}/change"]["post"]["responses"]["200"]["content"];
|
||||
let content = &doc["paths"]["/change"]["post"]["responses"]["200"]["content"];
|
||||
let schema = &content["application/json"]["schema"];
|
||||
let ref_path = schema["$ref"].as_str().unwrap();
|
||||
assert!(
|
||||
|
|
@ -899,11 +863,11 @@ fn healthz_200_references_health_output_schema() {
|
|||
fn error_responses_reference_error_output_schema() {
|
||||
let doc = openapi_json();
|
||||
let paths_with_errors = [
|
||||
("/graphs/{graph_id}/read", "post", "400"),
|
||||
("/graphs/{graph_id}/read", "post", "401"),
|
||||
("/graphs/{graph_id}/change", "post", "400"),
|
||||
("/graphs/{graph_id}/change", "post", "409"),
|
||||
("/graphs/{graph_id}/branches", "post", "409"),
|
||||
("/read", "post", "400"),
|
||||
("/read", "post", "401"),
|
||||
("/change", "post", "400"),
|
||||
("/change", "post", "409"),
|
||||
("/branches", "post", "409"),
|
||||
];
|
||||
|
||||
for (path, method, status) in paths_with_errors {
|
||||
|
|
@ -925,13 +889,13 @@ fn error_responses_reference_error_output_schema() {
|
|||
fn post_endpoints_have_request_body() {
|
||||
let doc = openapi_json();
|
||||
let post_paths = [
|
||||
("/graphs/{graph_id}/read", "ReadRequest"),
|
||||
("/graphs/{graph_id}/change", "ChangeRequest"),
|
||||
("/graphs/{graph_id}/schema/apply", "SchemaApplyRequest"),
|
||||
("/graphs/{graph_id}/ingest", "IngestRequest"),
|
||||
("/graphs/{graph_id}/export", "ExportRequest"),
|
||||
("/graphs/{graph_id}/branches", "BranchCreateRequest"),
|
||||
("/graphs/{graph_id}/branches/merge", "BranchMergeRequest"),
|
||||
("/read", "ReadRequest"),
|
||||
("/change", "ChangeRequest"),
|
||||
("/schema/apply", "SchemaApplyRequest"),
|
||||
("/ingest", "IngestRequest"),
|
||||
("/export", "ExportRequest"),
|
||||
("/branches", "BranchCreateRequest"),
|
||||
("/branches/merge", "BranchMergeRequest"),
|
||||
];
|
||||
|
||||
for (path, expected_schema) in post_paths {
|
||||
|
|
@ -949,34 +913,6 @@ fn post_endpoints_have_request_body() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoke_stored_query_request_body_is_optional() {
|
||||
let doc = openapi_json();
|
||||
let request_body = &doc["paths"]["/graphs/{graph_id}/queries/{name}"]["post"]["requestBody"];
|
||||
assert!(
|
||||
request_body.is_object(),
|
||||
"POST /queries/{{name}} should document its optional request body"
|
||||
);
|
||||
assert_eq!(
|
||||
request_body["required"].as_bool().unwrap_or(false),
|
||||
false,
|
||||
"stored-query invocation body should be optional"
|
||||
);
|
||||
let schema = &request_body["content"]["application/json"]["schema"];
|
||||
let ref_path = schema["$ref"]
|
||||
.as_str()
|
||||
.or_else(|| {
|
||||
schema["oneOf"]
|
||||
.as_array()
|
||||
.and_then(|schemas| schemas.iter().find_map(|schema| schema["$ref"].as_str()))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
ref_path.contains("InvokeStoredQueryRequest"),
|
||||
"POST /queries/{{name}} requestBody should reference InvokeStoredQueryRequest, got {ref_path}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization round-trip test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1055,14 +991,12 @@ async fn auth_mode_spec_has_security_on_protected_operations() {
|
|||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (_, json) = json_response(&app, request).await;
|
||||
// RFC-011 cluster-only: the served spec always nests protected
|
||||
// routes under `/graphs/{graph_id}/...`.
|
||||
let protected_paths = [
|
||||
("/graphs/{graph_id}/read", "post"),
|
||||
("/graphs/{graph_id}/change", "post"),
|
||||
("/graphs/{graph_id}/snapshot", "get"),
|
||||
("/graphs/{graph_id}/branches", "get"),
|
||||
("/graphs/{graph_id}/commits", "get"),
|
||||
("/read", "post"),
|
||||
("/change", "post"),
|
||||
("/snapshot", "get"),
|
||||
("/branches", "get"),
|
||||
("/commits", "get"),
|
||||
];
|
||||
for (path, method) in protected_paths {
|
||||
let security = &json["paths"][path][method]["security"];
|
||||
|
|
@ -1079,6 +1013,22 @@ async fn auth_mode_spec_has_security_on_protected_operations() {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_mode_spec_matches_static_generation() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth("secret").await;
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/openapi.json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let (_, served) = json_response(&app, request).await;
|
||||
let static_doc = openapi_json();
|
||||
assert_eq!(
|
||||
served, static_doc,
|
||||
"auth-mode served spec must match static generation"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auth_mode_healthz_still_has_no_security() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth("secret").await;
|
||||
|
|
@ -1167,7 +1117,6 @@ async fn app_for_multi_mode(graph_ids: &[&str]) -> (Vec<tempfile::TempDir>, Rout
|
|||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
}));
|
||||
dirs.push(dir);
|
||||
}
|
||||
|
|
@ -1384,9 +1333,8 @@ async fn multi_mode_operation_ids_are_unique() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn served_spec_always_nests_under_cluster_prefix() {
|
||||
// RFC-011 cluster-only: even a one-graph convenience app serves the
|
||||
// nested cluster surface and never the flat protected routes.
|
||||
async fn single_mode_openapi_unchanged_by_cluster_filter() {
|
||||
// Regression: single mode still emits the legacy flat surface.
|
||||
let (_temp, app) = app_for_loaded_graph().await;
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
|
|
@ -1396,37 +1344,16 @@ async fn served_spec_always_nests_under_cluster_prefix() {
|
|||
let (_, json) = json_response(&app, request).await;
|
||||
let paths = json["paths"].as_object().unwrap();
|
||||
let path_keys: HashSet<&str> = paths.keys().map(|k| k.as_str()).collect();
|
||||
for cluster in EXPECTED_CLUSTER_PATHS {
|
||||
for expected in EXPECTED_PATHS {
|
||||
assert!(
|
||||
path_keys.contains(cluster),
|
||||
"served spec must emit cluster path: {cluster}. Found: {path_keys:?}"
|
||||
path_keys.contains(expected),
|
||||
"single mode must still emit flat path: {expected}"
|
||||
);
|
||||
}
|
||||
// The flat protected routes must NOT appear — only the nested
|
||||
// cluster surface plus the always-flat `/healthz` and `/graphs`.
|
||||
let flat_protected = [
|
||||
"/snapshot",
|
||||
"/read",
|
||||
"/query",
|
||||
"/export",
|
||||
"/change",
|
||||
"/mutate",
|
||||
"/queries",
|
||||
"/queries/{name}",
|
||||
"/schema",
|
||||
"/schema/apply",
|
||||
"/load",
|
||||
"/ingest",
|
||||
"/branches",
|
||||
"/branches/{branch}",
|
||||
"/branches/merge",
|
||||
"/commits",
|
||||
"/commits/{commit_id}",
|
||||
];
|
||||
for flat in flat_protected {
|
||||
for cluster in EXPECTED_CLUSTER_PATHS {
|
||||
assert!(
|
||||
!path_keys.contains(flat),
|
||||
"served spec must NOT emit flat protected path: {flat}"
|
||||
!path_keys.contains(cluster),
|
||||
"single mode must NOT emit cluster path: {cluster}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,179 +0,0 @@
|
|||
//! S3-backed single-graph serving (gated on OMNIGRAPH_S3_TEST_BUCKET).
|
||||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use omnigraph::db::Omnigraph;
|
||||
use omnigraph::loader::{LoadMode, load_jsonl};
|
||||
use omnigraph_server::api::ReadRequest;
|
||||
use omnigraph_server::{AppState, build_app};
|
||||
use serde_json::json;
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn server_opens_s3_graph_directly_and_serves_snapshot_and_read() {
|
||||
let Some(uri) = s3_test_graph_uri("server") else {
|
||||
eprintln!("skipping s3 server test: OMNIGRAPH_S3_TEST_BUCKET is not set");
|
||||
return;
|
||||
};
|
||||
|
||||
Omnigraph::init(&uri, &fs::read_to_string(fixture("test.pg")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut db = Omnigraph::open(&uri).await.unwrap();
|
||||
load_jsonl(
|
||||
&mut db,
|
||||
&fs::read_to_string(fixture("test.jsonl")).unwrap(),
|
||||
LoadMode::Overwrite,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let app = build_app(
|
||||
AppState::open_with_bearer_token(uri.clone(), Some("s3-token".to_string()))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
let (snapshot_status, snapshot_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer s3-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(snapshot_status, StatusCode::OK);
|
||||
assert!(snapshot_body["tables"].is_array());
|
||||
|
||||
let read = ReadRequest {
|
||||
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
||||
query_name: Some("get_person".to_string()),
|
||||
params: Some(json!({ "name": "Alice" })),
|
||||
branch: Some("main".to_string()),
|
||||
snapshot: None,
|
||||
};
|
||||
let (read_status, read_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/read"))
|
||||
.method(Method::POST)
|
||||
.header("authorization", "Bearer s3-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(read_status, StatusCode::OK);
|
||||
assert_eq!(read_body["row_count"], 1);
|
||||
assert_eq!(read_body["rows"][0]["p.name"], "Alice");
|
||||
}
|
||||
|
||||
/// Config-free cluster serving (RFC-006): boot `--cluster s3://bucket/prefix`
|
||||
/// with NO local files at all — the ledger and catalog on the bucket are the
|
||||
/// whole deployment artifact. The fixture cluster is applied from a temp
|
||||
/// config dir, which is then dropped before the server boots from the URI.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn server_boots_cluster_from_bare_storage_uri_and_serves_query() {
|
||||
let Some(bucket) = std::env::var("OMNIGRAPH_S3_TEST_BUCKET").ok() else {
|
||||
eprintln!("skipping s3 cluster-serving test: OMNIGRAPH_S3_TEST_BUCKET is not set");
|
||||
return;
|
||||
};
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let root = format!("s3://{bucket}/cluster-serve/{unique}");
|
||||
|
||||
// Apply a one-graph cluster onto the bucket, seed it, then DROP the
|
||||
// config dir — the boot below must need nothing local.
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
dir.path().join("people.pg"),
|
||||
"node Person {\n name: String @key\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("people.gq"),
|
||||
"query find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("cluster.yaml"),
|
||||
format!(
|
||||
"version: 1\nstorage: {root}\ngraphs:\n knowledge:\n schema: people.pg\n queries:\n find_person:\n file: people.gq\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let import = omnigraph_cluster::import_config_dir(dir.path()).await;
|
||||
assert!(import.ok, "{:?}", import.diagnostics);
|
||||
let apply = omnigraph_cluster::apply_config_dir(dir.path()).await;
|
||||
assert!(apply.ok && apply.converged, "{:?}", apply.diagnostics);
|
||||
|
||||
let graph_uri = format!("{root}/graphs/knowledge.omni");
|
||||
let mut db = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
load_jsonl(
|
||||
&mut db,
|
||||
"{\"type\":\"Person\",\"data\":{\"name\":\"Ada\"}}\n",
|
||||
LoadMode::Overwrite,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let settings = omnigraph_server::load_server_settings(
|
||||
Some(&std::path::PathBuf::from(&root)),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let omnigraph_server::ServerConfigMode::Multi {
|
||||
graphs,
|
||||
config_path,
|
||||
server_policy,
|
||||
} = settings.mode
|
||||
else {
|
||||
panic!("cluster boot must select multi-graph routing");
|
||||
};
|
||||
let state = omnigraph_server::open_multi_graph_state(
|
||||
graphs,
|
||||
Vec::new(),
|
||||
server_policy.as_ref(),
|
||||
config_path,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let response = tower::ServiceExt::oneshot(
|
||||
app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/graphs/knowledge/queries/find_person")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"params": {"name": "Ada"}}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(value["rows"][0]["p.name"], "Ada", "{value}");
|
||||
}
|
||||
|
|
@ -1,950 +0,0 @@
|
|||
//! Schema read/apply routes: migrations over HTTP, drift, gating.
|
||||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use lance::index::DatasetIndexExt;
|
||||
use omnigraph::db::{Omnigraph, ReadTarget};
|
||||
use omnigraph::loader::LoadMode;
|
||||
use omnigraph_server::api::{
|
||||
ChangeRequest, ErrorOutput, ReadRequest, SchemaApplyRequest, SchemaOutput,
|
||||
};
|
||||
use omnigraph_server::{
|
||||
AppState, GraphHandle, GraphId, GraphKey, PolicyEngine, build_app, workload,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_updates_graph_for_authorized_admin() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let schema = additive_schema_with_nickname();
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: schema,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
let graph = graph_path(temp.path());
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
assert!(
|
||||
reopened.catalog().node_types["Person"]
|
||||
.properties
|
||||
.contains_key("nickname")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_refuses_cluster_backed_server_mode() {
|
||||
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
||||
let graph = graph_path(temp.path());
|
||||
let graph_uri = graph.to_string_lossy().to_string();
|
||||
let engine = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("default").unwrap()),
|
||||
uri: graph_uri.clone(),
|
||||
engine: Arc::new(engine),
|
||||
policy: None,
|
||||
queries: None,
|
||||
});
|
||||
let state = AppState::new_multi(
|
||||
vec![handle],
|
||||
Vec::new(),
|
||||
None,
|
||||
workload::WorkloadController::from_env(),
|
||||
Some(temp.path().join("cluster.yaml")),
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::CONFLICT, "body: {payload}");
|
||||
assert!(
|
||||
payload["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("cluster apply"),
|
||||
"body: {payload}"
|
||||
);
|
||||
let reopened = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
assert!(
|
||||
!reopened.catalog().node_types["Person"]
|
||||
.properties
|
||||
.contains_key("nickname"),
|
||||
"cluster-backed schema apply must not mutate the graph"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_cluster_backed_denies_unauthorized_actor_before_409() {
|
||||
// The cluster-backed 409 is reported AFTER the Cedar gate, so an actor
|
||||
// without `schema_apply` permission gets a 403 — never a 409 that would
|
||||
// disclose the server is cluster-backed (401 → 403 → 409, no topology leak
|
||||
// before authorization). POLICY_YAML grants read/export but not schema_apply,
|
||||
// so act-ragnor is denied.
|
||||
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
||||
let graph = graph_path(temp.path());
|
||||
let graph_uri = graph.to_string_lossy().to_string();
|
||||
let engine = Omnigraph::open(&graph_uri).await.unwrap();
|
||||
let policy = PolicyEngine::load_graph_from_source(POLICY_YAML, "default").unwrap();
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key: GraphKey::cluster(GraphId::try_from("default").unwrap()),
|
||||
uri: graph_uri,
|
||||
engine: Arc::new(engine),
|
||||
policy: Some(Arc::new(policy)),
|
||||
queries: None,
|
||||
});
|
||||
let state = AppState::new_multi(
|
||||
vec![handle],
|
||||
vec![("act-ragnor".to_string(), "admin-token".to_string())],
|
||||
None,
|
||||
workload::WorkloadController::from_env(),
|
||||
Some(temp.path().join("cluster.yaml")),
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"an unauthorized actor must get 403 before the cluster-backed 409: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_rejects_stored_query_breakage_before_publish() {
|
||||
let (temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, true)],
|
||||
&[("act-ragnor", "admin-token")],
|
||||
STORED_QUERY_SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: renamed_age_schema(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {payload}");
|
||||
let message = payload["error"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
message.contains("find_person") && message.contains("schema check"),
|
||||
"registry breakage should name the stored query; body: {payload}"
|
||||
);
|
||||
|
||||
let reopened = Omnigraph::open(graph_path(temp.path()).to_str().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let person = &reopened.catalog().node_types["Person"];
|
||||
assert!(person.properties.contains_key("age"));
|
||||
assert!(!person.properties.contains_key("years"));
|
||||
|
||||
let (invoke_status, invoke_body) = json_response(
|
||||
&app,
|
||||
invoke_request(
|
||||
"find_person",
|
||||
"admin-token",
|
||||
json!({ "params": { "name": "Alice" } }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(invoke_status, StatusCode::OK, "body: {invoke_body}");
|
||||
assert_eq!(invoke_body["row_count"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_noop_keeps_valid_stored_query_registry() {
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, true)],
|
||||
&[("act-ragnor", "admin-token")],
|
||||
STORED_QUERY_SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {payload}");
|
||||
assert_eq!(payload["applied"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_requires_schema_apply_policy_permission() {
|
||||
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
payload["code"],
|
||||
serde_json::to_value(omnigraph_server::api::ErrorCode::Forbidden).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_requires_bearer_token_when_policy_enabled() {
|
||||
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
payload["code"],
|
||||
serde_json::to_value(omnigraph_server::api::ErrorCode::Unauthorized).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_can_rename_type() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: renamed_person_schema(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
let graph = graph_path(temp.path());
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
let snapshot = reopened
|
||||
.snapshot_of(ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(snapshot.entry("node:Human").is_some());
|
||||
assert!(snapshot.entry("node:Person").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_can_rename_property() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: renamed_age_schema(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
let graph = graph_path(temp.path());
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
let person = &reopened.catalog().node_types["Person"];
|
||||
assert!(person.properties.contains_key("years"));
|
||||
assert!(!person.properties.contains_key("age"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_can_add_index() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let graph = graph_path(temp.path());
|
||||
let before_index_count = {
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
let snapshot = db.snapshot_of(ReadTarget::branch("main")).await.unwrap();
|
||||
let dataset = snapshot.open("node:Person").await.unwrap();
|
||||
dataset.load_indices().await.unwrap().len()
|
||||
};
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: indexed_name_schema(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
// iss-848: the /schema/apply route accepts the index-add and applies it as a
|
||||
// metadata change — it records the `@index` intent in the catalog/IR but does
|
||||
// NOT build the physical index inline (the build is deferred to
|
||||
// ensure_indices/optimize; on this empty table nothing would build anyway).
|
||||
// So the physical index count is unchanged by the apply.
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
let snapshot = reopened
|
||||
.snapshot_of(ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap();
|
||||
let dataset = snapshot.open("node:Person").await.unwrap();
|
||||
let after_index_count = dataset.load_indices().await.unwrap().len();
|
||||
assert_eq!(
|
||||
after_index_count, before_index_count,
|
||||
"schema apply records @index intent but defers the physical build (iss-848)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_rejects_unsupported_plan() {
|
||||
let (_temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: unsupported_schema_change(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
payload["code"],
|
||||
serde_json::to_value(omnigraph_server::api::ErrorCode::BadRequest).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_apply_route_rejects_when_non_main_branch_exists() {
|
||||
let temp = init_graph_with_schema(&fs::read_to_string(fixture("test.pg")).unwrap()).await;
|
||||
let graph = graph_path(temp.path());
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
db.branch_create("feature").await.unwrap();
|
||||
drop(db);
|
||||
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
fs::write(&policy_path, SCHEMA_APPLY_POLICY_YAML).unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![("act-ragnor".to_string(), "admin-token".to_string())],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let request = Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
let (status, payload) = json_response(&app, request).await;
|
||||
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
payload["code"],
|
||||
serde_json::to_value(omnigraph_server::api::ErrorCode::Conflict).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_drift_returns_conflict_for_snapshot_read_and_change() {
|
||||
let (temp, app) = app_for_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
fs::write(graph.join("_schema.pg"), drifted_test_schema()).unwrap();
|
||||
|
||||
let (snapshot_status, snapshot_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/snapshot?branch=main"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let snapshot_error: ErrorOutput = serde_json::from_value(snapshot_body).unwrap();
|
||||
assert_eq!(snapshot_status, StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
snapshot_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Conflict)
|
||||
);
|
||||
assert!(
|
||||
snapshot_error
|
||||
.error
|
||||
.contains("schema evolution is locked down in phase 1")
|
||||
);
|
||||
|
||||
let read = ReadRequest {
|
||||
query_source: fs::read_to_string(fixture("test.gq")).unwrap(),
|
||||
query_name: Some("get_person".to_string()),
|
||||
params: Some(json!({ "name": "Alice" })),
|
||||
branch: Some("main".to_string()),
|
||||
snapshot: None,
|
||||
};
|
||||
let (read_status, read_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/read"))
|
||||
.method(Method::POST)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&read).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let read_error: ErrorOutput = serde_json::from_value(read_body).unwrap();
|
||||
assert_eq!(read_status, StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
read_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Conflict)
|
||||
);
|
||||
assert!(
|
||||
read_error
|
||||
.error
|
||||
.contains("schema evolution is locked down in phase 1")
|
||||
);
|
||||
|
||||
let change = ChangeRequest {
|
||||
query: MUTATION_QUERIES.to_string(),
|
||||
name: Some("insert_person".to_string()),
|
||||
params: Some(json!({ "name": "Mina", "age": 28 })),
|
||||
branch: Some("main".to_string()),
|
||||
};
|
||||
let (change_status, change_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/change"))
|
||||
.method(Method::POST)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&change).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let change_error: ErrorOutput = serde_json::from_value(change_body).unwrap();
|
||||
assert_eq!(change_status, StatusCode::CONFLICT);
|
||||
assert_eq!(
|
||||
change_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Conflict)
|
||||
);
|
||||
assert!(
|
||||
change_error
|
||||
.error
|
||||
.contains("schema evolution is locked down in phase 1")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_route_returns_current_source() {
|
||||
let (_temp, app) = app_for_loaded_graph().await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/schema"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let output: SchemaOutput = serde_json::from_value(body).unwrap();
|
||||
assert!(output.schema_source.contains("node Person"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_route_requires_bearer_token_when_auth_configured() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
||||
|
||||
let (missing_status, missing_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/schema"))
|
||||
.method(Method::GET)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let missing_error: ErrorOutput = serde_json::from_value(missing_body).unwrap();
|
||||
assert_eq!(missing_status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
missing_error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Unauthorized)
|
||||
);
|
||||
|
||||
let (ok_status, ok_body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/schema"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer demo-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(ok_status, StatusCode::OK);
|
||||
let output: SchemaOutput = serde_json::from_value(ok_body).unwrap();
|
||||
assert!(!output.schema_source.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_route_denied_when_actor_lacks_read_permission() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let policy_path = temp.path().join("policy.yaml");
|
||||
// Policy grants branch_create only — no read action for act-bruno.
|
||||
fs::write(&policy_path, INGEST_CREATE_ONLY_POLICY_YAML).unwrap();
|
||||
let state = AppState::open_with_bearer_tokens_and_policy(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![("act-bruno".to_string(), "team-token".to_string())],
|
||||
Some(&policy_path),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.uri(g("/schema"))
|
||||
.method(Method::GET)
|
||||
.header("authorization", "Bearer team-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let error: ErrorOutput = serde_json::from_value(body).unwrap();
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
error.code,
|
||||
Some(omnigraph_server::api::ErrorCode::Forbidden)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_soft_drops_property_via_http() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
// Load a row that has the column we're about to drop.
|
||||
let graph = graph_path(temp.path());
|
||||
{
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
db.load(
|
||||
"main",
|
||||
r#"{"type":"Person","data":{"name":"PreDrop","age":42}}"#,
|
||||
LoadMode::Append,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let pre_version = manifest_dataset_version(&graph).await;
|
||||
|
||||
let (status, payload) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: schema_without_age(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
// Catalog reflects the drop: `age` is gone from the live schema.
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
assert!(
|
||||
!reopened.catalog().node_types["Person"]
|
||||
.properties
|
||||
.contains_key("age"),
|
||||
"catalog should not contain `age` after drop"
|
||||
);
|
||||
|
||||
// Soft drop preserves the prior version — `age` is still readable
|
||||
// via time travel to the pre-drop manifest version. Mirrors the
|
||||
// SDK-side assertion in `apply_schema_drops_a_nullable_property_softly_preserves_prior_version`.
|
||||
let pre_drop_snapshot = reopened.snapshot_at_version(pre_version).await.unwrap();
|
||||
let pre_drop_ds = pre_drop_snapshot.open("node:Person").await.unwrap();
|
||||
let pre_drop_fields = pre_drop_ds
|
||||
.schema()
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| f.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
pre_drop_fields.iter().any(|f| f == "age"),
|
||||
"soft drop should leave the pre-drop dataset's `age` column \
|
||||
time-travel-reachable; got fields {pre_drop_fields:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_soft_drops_node_type_via_http() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let graph = graph_path(temp.path());
|
||||
|
||||
let (status, payload) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: schema_without_company(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
assert!(
|
||||
!reopened.catalog().node_types.contains_key("Company"),
|
||||
"catalog should not contain `Company` after drop"
|
||||
);
|
||||
assert!(
|
||||
!reopened.catalog().edge_types.contains_key("WorksAt"),
|
||||
"catalog should not contain `WorksAt` after cascade"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_hard_drops_property_with_allow_data_loss() {
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let graph = graph_path(temp.path());
|
||||
{
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
db.load(
|
||||
"main",
|
||||
r#"{"type":"Person","data":{"name":"PreDropHard","age":50}}"#,
|
||||
LoadMode::Append,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Apply with allow_data_loss=true → Hard mode promotion.
|
||||
let (status, payload) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: schema_without_age(),
|
||||
allow_data_loss: true,
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
// Catalog reflects the drop.
|
||||
let reopened = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
assert!(
|
||||
!reopened.catalog().node_types["Person"]
|
||||
.properties
|
||||
.contains_key("age"),
|
||||
"catalog should not contain `age` after Hard drop"
|
||||
);
|
||||
// Plan steps should show DropMode::Hard for property drops.
|
||||
let steps = payload["steps"].as_array().expect("steps array");
|
||||
let drop_step = steps
|
||||
.iter()
|
||||
.find(|s| s["kind"] == "drop_property")
|
||||
.expect("plan should include drop_property step");
|
||||
let mode = &drop_step["mode"];
|
||||
assert_eq!(
|
||||
mode, "hard",
|
||||
"expected hard mode under allow_data_loss=true"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_keeps_drops_soft_without_flag() {
|
||||
// Symmetric to the Hard test: same schema change, but no
|
||||
// allow_data_loss flag → drops stay Soft (prior column data
|
||||
// remains time-travel-reachable). Pins the default semantics
|
||||
// against accidental Hard promotion.
|
||||
let (temp, app) = app_for_graph_with_auth_tokens_and_policy(
|
||||
&fs::read_to_string(fixture("test.pg")).unwrap(),
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let graph = graph_path(temp.path());
|
||||
|
||||
let (status, payload) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: schema_without_age(),
|
||||
allow_data_loss: false,
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
let steps = payload["steps"].as_array().expect("steps array");
|
||||
let drop_step = steps
|
||||
.iter()
|
||||
.find(|s| s["kind"] == "drop_property")
|
||||
.expect("plan should include drop_property step");
|
||||
let mode = &drop_step["mode"];
|
||||
assert_eq!(mode, "soft", "expected soft mode without allow_data_loss");
|
||||
let _ = graph;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn schema_apply_route_additive_property_preserves_existing_rows() {
|
||||
// SDK suite covers rename and drop data preservation. Additive
|
||||
// AddProperty wasn't pinned with a row-count check anywhere.
|
||||
// Load N rows, apply schema adding nullable property, verify
|
||||
// every row is still readable and the new column is null.
|
||||
let (temp, app) = app_for_loaded_graph_with_auth_tokens_and_policy(
|
||||
&[("act-ragnor", "admin-token")],
|
||||
SCHEMA_APPLY_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let graph = graph_path(temp.path());
|
||||
|
||||
// Standard fixture data is loaded before the app is built, so the server
|
||||
// handle applies schema from the same manifest it is serving.
|
||||
let pre_count = {
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
let snap = db
|
||||
.snapshot_of(omnigraph::db::ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap();
|
||||
snap.open("node:Person")
|
||||
.await
|
||||
.expect("Person")
|
||||
.count_rows(None)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
assert!(pre_count > 0, "fixture should have loaded Person rows");
|
||||
|
||||
let (status, payload) = json_response(
|
||||
&app,
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(g("/schema/apply"))
|
||||
.header("content-type", "application/json")
|
||||
.header("authorization", "Bearer admin-token")
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&SchemaApplyRequest {
|
||||
schema_source: additive_schema_with_nickname(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(payload["applied"], true);
|
||||
|
||||
// Row count preserved.
|
||||
let db = Omnigraph::open(graph.to_str().unwrap()).await.unwrap();
|
||||
let snap = db
|
||||
.snapshot_of(omnigraph::db::ReadTarget::branch("main"))
|
||||
.await
|
||||
.unwrap();
|
||||
let post_count = snap
|
||||
.open("node:Person")
|
||||
.await
|
||||
.expect("Person")
|
||||
.count_rows(None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
post_count, pre_count,
|
||||
"AddProperty should preserve row count",
|
||||
);
|
||||
}
|
||||
5580
crates/omnigraph-server/tests/server.rs
Normal file
5580
crates/omnigraph-server/tests/server.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,422 +0,0 @@
|
|||
//! Stored-query registry boot, /queries listing, and invocation routes.
|
||||
//! Moved verbatim from tests/server.rs in the modularization.
|
||||
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::StatusCode;
|
||||
use omnigraph_server::AppState;
|
||||
use serde_json::json;
|
||||
|
||||
|
||||
mod support;
|
||||
use support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_boots_with_a_valid_stored_query_registry() {
|
||||
// A stored query that type-checks against the fixture schema
|
||||
// (`Person { name, age }`) must let the server boot.
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let registry = stored_query_registry(&[(
|
||||
"find_person",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
false,
|
||||
)]);
|
||||
let state = AppState::open_single_with_queries(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![],
|
||||
None,
|
||||
registry,
|
||||
)
|
||||
.await;
|
||||
assert!(state.is_ok(), "valid registry should boot: {:?}", state.err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_refuses_boot_on_type_broken_stored_query() {
|
||||
// A stored query referencing a type not in the schema (`Widget`)
|
||||
// must abort boot, naming the offending query.
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let registry = stored_query_registry(&[(
|
||||
"ghost",
|
||||
"query ghost() { match { $w: Widget } return { $w.name } }",
|
||||
false,
|
||||
)]);
|
||||
let result = AppState::open_single_with_queries(
|
||||
graph.to_string_lossy().to_string(),
|
||||
vec![],
|
||||
None,
|
||||
registry,
|
||||
)
|
||||
.await;
|
||||
// `AppState` is not `Debug`, so match rather than `expect_err`.
|
||||
let err = match result {
|
||||
Ok(_) => panic!("type-broken stored query must refuse boot"),
|
||||
Err(err) => err,
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("ghost"), "error should name the broken query: {msg}");
|
||||
assert!(
|
||||
msg.contains("schema check"),
|
||||
"error should mention the schema check: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_stored_read_returns_rows() {
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, false)],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request("find_person", "t-invoke", json!({ "params": { "name": "Alice" } })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
assert_eq!(body["query_name"], "find_person");
|
||||
assert_eq!(body["row_count"], 1, "Alice is in the fixture; body: {body}");
|
||||
assert!(body["rows"].is_array(), "read envelope shape; body: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_with_mismatched_expected_kind_is_rejected() {
|
||||
// RFC-011 D3: the CLI verb asserts the stored query's kind via
|
||||
// `expect_mutation`. Invoking a read with `expect_mutation: true`
|
||||
// (i.e. `omnigraph mutate <a-read>`) is a 400 naming the right verb.
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, false)],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request(
|
||||
"find_person",
|
||||
"t-invoke",
|
||||
json!({ "expect_mutation": true, "params": { "name": "Alice" } }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
|
||||
assert!(
|
||||
body["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("'find_person' is a read — use omnigraph query find_person"),
|
||||
"expected a kind-mismatch error; body: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_with_matching_expected_kind_runs() {
|
||||
// The matching assertion (`omnigraph query <a-read>`) passes through.
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, false)],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request(
|
||||
"find_person",
|
||||
"t-invoke",
|
||||
json!({ "expect_mutation": false, "params": { "name": "Alice" } }),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "matching kind should run; body: {body}");
|
||||
assert_eq!(body["query_name"], "find_person");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_stored_read_accepts_absent_or_empty_body() {
|
||||
let no_param_query = "query list_people() { match { $p: Person } return { $p.name } }";
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("list_people", no_param_query, false)],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request_bytes("list_people", "t-invoke", Body::empty(), None),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
assert_eq!(body["query_name"], "list_people");
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request_bytes(
|
||||
"list_people",
|
||||
"t-invoke",
|
||||
Body::empty(),
|
||||
Some("application/json"),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request_bytes(
|
||||
"list_people",
|
||||
"t-invoke",
|
||||
Body::from("{}"),
|
||||
Some("application/json"),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request_bytes(
|
||||
"list_people",
|
||||
"t-invoke",
|
||||
Body::from("{"),
|
||||
Some("application/json"),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
|
||||
assert!(
|
||||
body["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("invalid stored-query invocation body"),
|
||||
"malformed JSON should be rejected as bad request; body: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_stored_mutation_double_gates_on_change() {
|
||||
let specs: &[(&str, &str, bool)] = &[(
|
||||
"add_person",
|
||||
"query add_person($name: String) { insert Person { name: $name } }",
|
||||
false,
|
||||
)];
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
specs,
|
||||
&[("act-invoke", "t-invoke"), ("act-full", "t-full")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Has invoke_query but NOT change → the inner change gate denies (403).
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request("add_person", "t-invoke", json!({ "params": { "name": "Eve" } })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"invoke_query without change must 403; body: {body}"
|
||||
);
|
||||
|
||||
// Has invoke_query + change → applied.
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request("add_person", "t-full", json!({ "params": { "name": "Eve" } })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
assert_eq!(body["affected_nodes"], 1, "body: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_stored_query_bad_param_is_400() {
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, false)],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
// `name` is declared String; pass a number.
|
||||
let (status, body) = json_response(
|
||||
&app,
|
||||
invoke_request("find_person", "t-invoke", json!({ "params": { "name": 123 } })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "body: {body}");
|
||||
assert!(
|
||||
body["error"].as_str().unwrap_or_default().contains("name"),
|
||||
"400 should name the offending param; body: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_unknown_query_and_denied_actor_return_identical_404() {
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, false)],
|
||||
&[("act-invoke", "t-invoke"), ("act-noinvoke", "t-noinvoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Authorized actor, unknown query name → 404.
|
||||
let (unknown_status, unknown_body) =
|
||||
json_response(&app, invoke_request("does_not_exist", "t-invoke", json!({}))).await;
|
||||
// Denied actor (no invoke_query), real query name → 404.
|
||||
let (denied_status, denied_body) = json_response(
|
||||
&app,
|
||||
invoke_request("find_person", "t-noinvoke", json!({ "params": { "name": "Alice" } })),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(unknown_status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(denied_status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(
|
||||
unknown_body, denied_body,
|
||||
"deny must be byte-identical to a missing query (no catalog probing)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn invoke_query_holder_without_read_sees_403_not_404() {
|
||||
// The 404-hiding is for callers WITHOUT invoke_query. An actor that
|
||||
// HOLDS invoke_query but lacks `read` clears the boundary gate, then the
|
||||
// inner read gate denies → 403 for an EXISTING read query, vs 404 for an
|
||||
// unknown one. Existence is visible to grant-holders by design (the
|
||||
// documented double-gate); this pins that actual contract.
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, false)],
|
||||
&[("act-invokeonly", "t-invokeonly")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (exists_status, _) = json_response(
|
||||
&app,
|
||||
invoke_request("find_person", "t-invokeonly", json!({ "params": { "name": "Alice" } })),
|
||||
)
|
||||
.await;
|
||||
let (absent_status, _) =
|
||||
json_response(&app, invoke_request("does_not_exist", "t-invokeonly", json!({}))).await;
|
||||
assert_eq!(
|
||||
exists_status,
|
||||
StatusCode::FORBIDDEN,
|
||||
"an existing read query the holder can't read → inner-gate 403"
|
||||
);
|
||||
assert_eq!(absent_status, StatusCode::NOT_FOUND, "unknown query still 404s");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn list_queries_returns_only_exposed_with_typed_params() {
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[
|
||||
("find_person", FIND_PERSON_GQ, true),
|
||||
(
|
||||
"add_person",
|
||||
"query add_person($name: String) { insert Person { name: $name } }",
|
||||
true,
|
||||
),
|
||||
("hidden", "query hidden() { match { $p: Person } return { $p.name } }", false),
|
||||
],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (status, body) = json_response(&app, get_request(&g("/queries"), "t-invoke")).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
|
||||
let entries = body["queries"].as_array().unwrap();
|
||||
let names: Vec<&str> = entries.iter().map(|q| q["name"].as_str().unwrap()).collect();
|
||||
assert!(
|
||||
names.contains(&"find_person") && names.contains(&"add_person"),
|
||||
"exposed queries listed: {names:?}"
|
||||
);
|
||||
assert!(!names.contains(&"hidden"), "non-exposed query hidden from the catalog: {names:?}");
|
||||
|
||||
let fp = entries.iter().find(|q| q["name"] == "find_person").unwrap();
|
||||
assert_eq!(fp["mutation"], false);
|
||||
assert_eq!(fp["tool_name"], "find_person");
|
||||
assert_eq!(fp["params"][0]["name"], "name");
|
||||
assert_eq!(fp["params"][0]["kind"], "string");
|
||||
let ap = entries.iter().find(|q| q["name"] == "add_person").unwrap();
|
||||
assert_eq!(ap["mutation"], true, "stored insert → mutation");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn list_queries_is_read_gated_so_a_non_invoker_can_list() {
|
||||
// The catalog is read-gated (not invoke_query-gated), so a reader who
|
||||
// lacks invoke_query still enumerates the exposed queries — the
|
||||
// documented probe-oracle gap until per-query Cedar filtering lands.
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[("find_person", FIND_PERSON_GQ, true)],
|
||||
&[("act-noinvoke", "t-noinvoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (status, body) = json_response(&app, get_request(&g("/queries"), "t-noinvoke")).await;
|
||||
assert_eq!(status, StatusCode::OK, "read-gated catalog; body: {body}");
|
||||
let names: Vec<&str> = body["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|q| q["name"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
names.contains(&"find_person"),
|
||||
"a reader lists the catalog despite lacking invoke_query: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn list_queries_surfaces_query_description_and_instruction() {
|
||||
// E2e for the query-level `.gq` surface: `@description`/`@instruction` on
|
||||
// a stored query declaration are carried through to clients via the typed
|
||||
// `QueryCatalogEntry` fields over `GET /queries`. A query without them
|
||||
// omits both fields (serde `skip_serializing_if = "Option::is_none"`).
|
||||
let described = "query described($name: String) \
|
||||
@description(\"Find a person by exact name.\") \
|
||||
@instruction(\"Use for exact lookups; prefer search for fuzzy matches.\") \
|
||||
{ match { $p: Person { name: $name } } return { $p.age } }";
|
||||
let (_temp, app) = app_with_stored_queries(
|
||||
&[
|
||||
("described", described, true),
|
||||
("bare", "query bare() { match { $p: Person } return { $p.name } }", true),
|
||||
],
|
||||
&[("act-invoke", "t-invoke")],
|
||||
INVOKE_POLICY_YAML,
|
||||
)
|
||||
.await;
|
||||
let (status, body) = json_response(&app, get_request(&g("/queries"), "t-invoke")).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
let entries = body["queries"].as_array().unwrap();
|
||||
|
||||
let described = entries.iter().find(|q| q["name"] == "described").unwrap();
|
||||
assert_eq!(
|
||||
described["description"], "Find a person by exact name.",
|
||||
"query @description surfaces over GET /queries: {described}"
|
||||
);
|
||||
assert_eq!(
|
||||
described["instruction"],
|
||||
"Use for exact lookups; prefer search for fuzzy matches.",
|
||||
"query @instruction surfaces over GET /queries: {described}"
|
||||
);
|
||||
|
||||
let bare = entries.iter().find(|q| q["name"] == "bare").unwrap();
|
||||
assert!(
|
||||
bare.get("description").is_none() && bare.get("instruction").is_none(),
|
||||
"a query without the annotations omits both fields: {bare}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn list_queries_is_empty_when_no_registry() {
|
||||
let (_temp, app) = app_for_loaded_graph_with_auth("demo-token").await;
|
||||
let (status, body) = json_response(&app, get_request(&g("/queries"), "demo-token")).await;
|
||||
assert_eq!(status, StatusCode::OK, "body: {body}");
|
||||
assert!(
|
||||
body["queries"].as_array().unwrap().is_empty(),
|
||||
"no stored-query registry → empty catalog"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "omnigraph-engine"
|
||||
version = "0.7.2"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
description = "Runtime engine for the Omnigraph graph database."
|
||||
license = "MIT"
|
||||
|
|
@ -16,8 +16,8 @@ default = []
|
|||
failpoints = ["dep:fail", "fail/failpoints"]
|
||||
|
||||
[dependencies]
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.2" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.7.2" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.6.0" }
|
||||
omnigraph-policy = { path = "../omnigraph-policy", version = "0.6.0" }
|
||||
lance = { workspace = true }
|
||||
lance-datafusion = { workspace = true }
|
||||
datafusion = { workspace = true }
|
||||
|
|
@ -37,7 +37,6 @@ serde_json = { workspace = true }
|
|||
reqwest = { workspace = true }
|
||||
object_store = { workspace = true }
|
||||
ulid = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
|
@ -52,9 +51,7 @@ chrono = { workspace = true }
|
|||
arc-swap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.7.2" }
|
||||
omnigraph-compiler = { path = "../omnigraph-compiler", version = "0.6.0" }
|
||||
tokio = { workspace = true }
|
||||
lance-namespace-impls = { workspace = true }
|
||||
lance-io = "7.0.0"
|
||||
serial_test = "3"
|
||||
proptest = "1"
|
||||
|
|
|
|||
|
|
@ -221,65 +221,6 @@ fn microbench_dedup() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Selective single-source traversal, timed cold in CSR vs indexed mode across
|
||||
/// growing |E|. The win of the indexed path: a small fixed frontier should be
|
||||
/// ~flat in |E| (one BTREE scan per hop), whereas CSR pays an O(|E|) adjacency
|
||||
/// build on the first (cold) query. Also asserts both modes return the same
|
||||
/// rows — a guard against the scalar-index `physical_rows` silent fallback
|
||||
/// dropping unindexed-fragment rows.
|
||||
async fn bench_selective_modes() {
|
||||
println!("\n── Selective traversal: indexed vs CSR (cold, single-source knows{{1,2}}) ──");
|
||||
let sel = r#"
|
||||
query sel($name: String) {
|
||||
match {
|
||||
$a: Person { name: $name }
|
||||
$a knows{1,2} $b
|
||||
}
|
||||
return { $b.name }
|
||||
}
|
||||
"#;
|
||||
for &(n, avg_deg) in &[(1_000usize, 8usize), (10_000, 8), (30_000, 8)] {
|
||||
let jsonl = generate_jsonl(n, avg_deg, 42);
|
||||
let mut params = ParamMap::new();
|
||||
params.insert(
|
||||
"name".to_string(),
|
||||
omnigraph_compiler::query::ast::Literal::String("p0".to_string()),
|
||||
);
|
||||
|
||||
let mut rows_by_mode: Vec<(&str, usize)> = Vec::new();
|
||||
for mode in ["csr", "indexed"] {
|
||||
// Fresh db per measurement so the query is cold (CSR pays its build).
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let uri = dir.path().to_str().unwrap();
|
||||
let mut db = Omnigraph::init(uri, SCHEMA).await.unwrap();
|
||||
load_jsonl(&mut db, &jsonl, LoadMode::Overwrite).await.unwrap();
|
||||
// SAFE: example main drives queries sequentially; no concurrent env reader.
|
||||
unsafe { std::env::set_var("OMNIGRAPH_TRAVERSAL_MODE", mode) };
|
||||
|
||||
let t = Instant::now();
|
||||
let r = db
|
||||
.query(ReadTarget::branch("main"), sel, "sel", ¶ms)
|
||||
.await
|
||||
.expect("sel query");
|
||||
let elapsed = t.elapsed();
|
||||
let rows = r.num_rows();
|
||||
rows_by_mode.push((mode, rows));
|
||||
println!(
|
||||
" |E|≈{:>7} {:<8} cold={:>9.2?} rows={}",
|
||||
n * avg_deg,
|
||||
mode,
|
||||
elapsed,
|
||||
rows
|
||||
);
|
||||
}
|
||||
unsafe { std::env::remove_var("OMNIGRAPH_TRAVERSAL_MODE") };
|
||||
assert_eq!(
|
||||
rows_by_mode[0].1, rows_by_mode[1].1,
|
||||
"indexed and CSR must return identical rows (no silent drop under partial index coverage)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
println!("── End-to-end query latency ──");
|
||||
|
|
@ -321,7 +262,5 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
bench_selective_modes().await;
|
||||
|
||||
microbench_dedup();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ use lance::dataset::scanner::ColumnOrdering;
|
|||
use crate::db::SubTableEntry;
|
||||
use crate::db::manifest::Snapshot;
|
||||
use crate::error::Result;
|
||||
use crate::storage_layer::{SnapshotHandle, TableStorage};
|
||||
use crate::table_store::TableStore;
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -230,8 +229,7 @@ async fn diff_table_same_lineage(
|
|||
) -> Result<Vec<EntityChange>> {
|
||||
let vf = from_entry.table_version;
|
||||
let vt = to_entry.table_version;
|
||||
let storage: &dyn TableStorage = table_store;
|
||||
let to_ds = storage.open_snapshot_at_entry(to_entry).await?;
|
||||
let to_ds = table_store.open_at_entry(to_entry).await?;
|
||||
|
||||
let cols: Vec<&str> = if is_edge {
|
||||
vec!["id", "src", "dst", "_row_last_updated_at_version"]
|
||||
|
|
@ -248,23 +246,23 @@ async fn diff_table_same_lineage(
|
|||
// Inserts + Updates: use _row_last_updated_at_version to find all rows
|
||||
// touched since Vf, then classify by checking whether the ID existed at Vf.
|
||||
//
|
||||
// We key on _row_last_updated_at_version because one scan over it catches
|
||||
// every row touched in the window — inserts and updates alike — regardless
|
||||
// of write mode, and ID-set membership at Vf then distinguishes inserts from
|
||||
// updates. (lance#6774 made merge_insert stamp new rows' _row_created_at_version
|
||||
// with the commit version, so created_at became reliable too; last_updated
|
||||
// stays the right key since it also covers updates.)
|
||||
// Why not _row_created_at_version for inserts: Lance's merge_insert stamps
|
||||
// new rows with _row_created_at_version = dataset_creation_version (v1),
|
||||
// not the merge_insert commit version. This makes _row_created_at_version
|
||||
// unreliable for detecting inserts from merge_insert writes. Using
|
||||
// _row_last_updated_at_version catches all touched rows regardless of
|
||||
// write mode, and ID-set membership distinguishes inserts from updates.
|
||||
if wants_inserts || wants_updates {
|
||||
let filter_sql = format!(
|
||||
"_row_last_updated_at_version > {} AND _row_last_updated_at_version <= {}",
|
||||
vf, vt
|
||||
);
|
||||
let changed_rows = scan_with_filter(storage, &to_ds, &cols, &filter_sql).await?;
|
||||
let changed_rows = scan_with_filter(table_store, &to_ds, &cols, &filter_sql).await?;
|
||||
|
||||
if !changed_rows.is_empty() {
|
||||
// Build the set of IDs that existed at the from version
|
||||
let from_ds = storage.open_snapshot_at_entry(from_entry).await?;
|
||||
let from_ids: HashSet<String> = scan_id_set(storage, &from_ds, &["id"])
|
||||
let from_ds = table_store.open_at_entry(from_entry).await?;
|
||||
let from_ids: HashSet<String> = scan_id_set(table_store, &from_ds, &["id"])
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.id)
|
||||
|
|
@ -284,8 +282,8 @@ async fn diff_table_same_lineage(
|
|||
|
||||
// Deletes: ID set-difference
|
||||
if wants_deletes {
|
||||
let from_ds = storage.open_snapshot_at_entry(from_entry).await?;
|
||||
let deleted = deleted_ids_by_set_diff(storage, &from_ds, &to_ds, is_edge).await?;
|
||||
let from_ds = table_store.open_at_entry(from_entry).await?;
|
||||
let deleted = deleted_ids_by_set_diff(table_store, &from_ds, &to_ds, is_edge).await?;
|
||||
changes.extend(deleted);
|
||||
}
|
||||
|
||||
|
|
@ -302,14 +300,13 @@ async fn diff_table_cross_branch(
|
|||
is_edge: bool,
|
||||
filter: &ChangeFilter,
|
||||
) -> Result<Vec<EntityChange>> {
|
||||
let storage: &dyn TableStorage = table_store;
|
||||
let from_ds = storage
|
||||
.open_snapshot_at_table(from_snap, table_key)
|
||||
let from_ds = table_store
|
||||
.open_snapshot_table(from_snap, table_key)
|
||||
.await?;
|
||||
let to_ds = storage.open_snapshot_at_table(to_snap, table_key).await?;
|
||||
let to_ds = table_store.open_snapshot_table(to_snap, table_key).await?;
|
||||
|
||||
let from_rows = scan_all_rows_ordered(storage, &from_ds, is_edge).await?;
|
||||
let to_rows = scan_all_rows_ordered(storage, &to_ds, is_edge).await?;
|
||||
let from_rows = scan_all_rows_ordered(table_store, &from_ds, is_edge).await?;
|
||||
let to_rows = scan_all_rows_ordered(table_store, &to_ds, is_edge).await?;
|
||||
|
||||
let mut changes = Vec::new();
|
||||
let mut fi = 0;
|
||||
|
|
@ -395,9 +392,8 @@ async fn diff_table_added(
|
|||
if !filter.wants_op(ChangeOp::Insert) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let storage: &dyn TableStorage = table_store;
|
||||
let ds = storage.open_snapshot_at_table(to_snap, table_key).await?;
|
||||
let rows = scan_all_rows_ordered(storage, &ds, is_edge).await?;
|
||||
let ds = table_store.open_snapshot_table(to_snap, table_key).await?;
|
||||
let rows = scan_all_rows_ordered(table_store, &ds, is_edge).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| entity_change_from_row(&r, ChangeOp::Insert, is_edge))
|
||||
|
|
@ -414,11 +410,10 @@ async fn diff_table_removed(
|
|||
if !filter.wants_op(ChangeOp::Delete) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let storage: &dyn TableStorage = table_store;
|
||||
let ds = storage
|
||||
.open_snapshot_at_table(from_snap, table_key)
|
||||
let ds = table_store
|
||||
.open_snapshot_table(from_snap, table_key)
|
||||
.await?;
|
||||
let rows = scan_all_rows_ordered(storage, &ds, is_edge).await?;
|
||||
let rows = scan_all_rows_ordered(table_store, &ds, is_edge).await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| entity_change_from_row(&r, ChangeOp::Delete, is_edge))
|
||||
|
|
@ -429,12 +424,12 @@ async fn diff_table_removed(
|
|||
|
||||
/// Scan with a SQL filter, projecting specific columns.
|
||||
async fn scan_with_filter(
|
||||
storage: &dyn TableStorage,
|
||||
ds: &SnapshotHandle,
|
||||
table_store: &TableStore,
|
||||
ds: &lance::Dataset,
|
||||
cols: &[&str],
|
||||
filter_sql: &str,
|
||||
) -> Result<Vec<ScannedRow>> {
|
||||
let batches = storage
|
||||
let batches = table_store
|
||||
.scan(ds, Some(cols), Some(filter_sql), None)
|
||||
.await?;
|
||||
Ok(extract_rows(&batches))
|
||||
|
|
@ -442,11 +437,11 @@ async fn scan_with_filter(
|
|||
|
||||
/// Scan all rows ordered by id, projecting id (+ src/dst for edges) + all columns for signature.
|
||||
async fn scan_all_rows_ordered(
|
||||
storage: &dyn TableStorage,
|
||||
ds: &SnapshotHandle,
|
||||
table_store: &TableStore,
|
||||
ds: &lance::Dataset,
|
||||
is_edge: bool,
|
||||
) -> Result<Vec<ScannedRow>> {
|
||||
let batches = storage
|
||||
let batches = table_store
|
||||
.scan(
|
||||
ds,
|
||||
None,
|
||||
|
|
@ -459,9 +454,9 @@ async fn scan_all_rows_ordered(
|
|||
|
||||
/// Compute deleted IDs: scan id at from and to, set-difference.
|
||||
async fn deleted_ids_by_set_diff(
|
||||
storage: &dyn TableStorage,
|
||||
from_ds: &SnapshotHandle,
|
||||
to_ds: &SnapshotHandle,
|
||||
table_store: &TableStore,
|
||||
from_ds: &lance::Dataset,
|
||||
to_ds: &lance::Dataset,
|
||||
is_edge: bool,
|
||||
) -> Result<Vec<EntityChange>> {
|
||||
let cols: Vec<&str> = if is_edge {
|
||||
|
|
@ -470,8 +465,8 @@ async fn deleted_ids_by_set_diff(
|
|||
vec!["id"]
|
||||
};
|
||||
|
||||
let from_rows = scan_id_set(storage, from_ds, &cols).await?;
|
||||
let to_ids: HashSet<String> = scan_id_set(storage, to_ds, &["id"])
|
||||
let from_rows = scan_id_set(table_store, from_ds, &cols).await?;
|
||||
let to_ids: HashSet<String> = scan_id_set(table_store, to_ds, &["id"])
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.id)
|
||||
|
|
@ -485,11 +480,11 @@ async fn deleted_ids_by_set_diff(
|
|||
}
|
||||
|
||||
async fn scan_id_set(
|
||||
storage: &dyn TableStorage,
|
||||
ds: &SnapshotHandle,
|
||||
table_store: &TableStore,
|
||||
ds: &lance::Dataset,
|
||||
cols: &[&str],
|
||||
) -> Result<Vec<ScannedRow>> {
|
||||
let batches = storage.scan(ds, Some(cols), None, None).await?;
|
||||
let batches = table_store.scan(ds, Some(cols), None, None).await?;
|
||||
Ok(extract_rows(&batches))
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue