Skip to main content

omnigraph_server/
api.rs

1use omnigraph::db::{GraphCommit, MergeOutcome, ReadTarget, SchemaApplyResult, Snapshot};
2use omnigraph::error::{MergeConflict, MergeConflictKind};
3use omnigraph::loader::{IngestResult, LoadMode};
4use crate::queries::StoredQuery;
5use omnigraph_compiler::SchemaMigrationStep;
6use omnigraph_compiler::query::ast::Param;
7use omnigraph_compiler::result::QueryResult;
8use omnigraph_compiler::types::{PropType, ScalarType};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use utoipa::{IntoParams, ToSchema};
12
13/// Shadow enum for documenting [`LoadMode`] in the OpenAPI schema.
14#[derive(ToSchema)]
15#[schema(as = LoadMode)]
16#[allow(dead_code)]
17enum LoadModeSchema {
18    /// Overwrite existing data.
19    #[schema(rename = "overwrite")]
20    Overwrite,
21    /// Append to existing data.
22    #[schema(rename = "append")]
23    Append,
24    /// Merge by id key (upsert).
25    #[schema(rename = "merge")]
26    Merge,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
30pub struct SnapshotTableOutput {
31    pub table_key: String,
32    pub table_path: String,
33    pub table_version: u64,
34    pub table_branch: Option<String>,
35    pub row_count: u64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
39pub struct SnapshotOutput {
40    pub branch: String,
41    pub manifest_version: u64,
42    pub tables: Vec<SnapshotTableOutput>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
46pub struct BranchCreateRequest {
47    /// Parent branch to fork from. Defaults to `main`.
48    pub from: Option<String>,
49    /// Name of the new branch. Must not already exist.
50    pub name: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
54pub struct BranchCreateOutput {
55    pub uri: String,
56    pub from: String,
57    pub name: String,
58    pub actor_id: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
62pub struct BranchListOutput {
63    pub branches: Vec<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
67pub struct BranchDeleteOutput {
68    pub uri: String,
69    pub name: String,
70    pub actor_id: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
74pub struct BranchMergeRequest {
75    /// Source branch whose commits will be merged.
76    pub source: String,
77    /// Target branch that will receive the merge. Defaults to `main`.
78    pub target: Option<String>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
82#[serde(rename_all = "snake_case")]
83pub enum BranchMergeOutcome {
84    AlreadyUpToDate,
85    FastForward,
86    Merged,
87}
88
89impl From<MergeOutcome> for BranchMergeOutcome {
90    fn from(value: MergeOutcome) -> Self {
91        match value {
92            MergeOutcome::AlreadyUpToDate => Self::AlreadyUpToDate,
93            MergeOutcome::FastForward => Self::FastForward,
94            MergeOutcome::Merged => Self::Merged,
95        }
96    }
97}
98
99impl BranchMergeOutcome {
100    pub fn as_str(self) -> &'static str {
101        match self {
102            Self::AlreadyUpToDate => "already_up_to_date",
103            Self::FastForward => "fast_forward",
104            Self::Merged => "merged",
105        }
106    }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
110pub struct BranchMergeOutput {
111    pub source: String,
112    pub target: String,
113    pub outcome: BranchMergeOutcome,
114    pub actor_id: Option<String>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
118#[serde(rename_all = "snake_case")]
119pub enum MergeConflictKindOutput {
120    DivergentInsert,
121    DivergentUpdate,
122    DeleteVsUpdate,
123    OrphanEdge,
124    UniqueViolation,
125    CardinalityViolation,
126    ValueConstraintViolation,
127}
128
129impl MergeConflictKindOutput {
130    pub fn as_str(self) -> &'static str {
131        match self {
132            Self::DivergentInsert => "divergent_insert",
133            Self::DivergentUpdate => "divergent_update",
134            Self::DeleteVsUpdate => "delete_vs_update",
135            Self::OrphanEdge => "orphan_edge",
136            Self::UniqueViolation => "unique_violation",
137            Self::CardinalityViolation => "cardinality_violation",
138            Self::ValueConstraintViolation => "value_constraint_violation",
139        }
140    }
141}
142
143impl From<MergeConflictKind> for MergeConflictKindOutput {
144    fn from(value: MergeConflictKind) -> Self {
145        match value {
146            MergeConflictKind::DivergentInsert => Self::DivergentInsert,
147            MergeConflictKind::DivergentUpdate => Self::DivergentUpdate,
148            MergeConflictKind::DeleteVsUpdate => Self::DeleteVsUpdate,
149            MergeConflictKind::OrphanEdge => Self::OrphanEdge,
150            MergeConflictKind::UniqueViolation => Self::UniqueViolation,
151            MergeConflictKind::CardinalityViolation => Self::CardinalityViolation,
152            MergeConflictKind::ValueConstraintViolation => Self::ValueConstraintViolation,
153        }
154    }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
158pub struct MergeConflictOutput {
159    pub table_key: String,
160    pub row_id: Option<String>,
161    pub kind: MergeConflictKindOutput,
162    pub message: String,
163}
164
165impl From<&MergeConflict> for MergeConflictOutput {
166    fn from(value: &MergeConflict) -> Self {
167        Self {
168            table_key: value.table_key.clone(),
169            row_id: value.row_id.clone(),
170            kind: value.kind.into(),
171            message: value.message.clone(),
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
177pub struct ReadTargetOutput {
178    pub branch: Option<String>,
179    pub snapshot: Option<String>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
183pub struct ReadOutput {
184    pub query_name: String,
185    pub target: ReadTargetOutput,
186    pub row_count: usize,
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub columns: Vec<String>,
189    pub rows: Value,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
193pub struct ChangeOutput {
194    pub branch: String,
195    pub query_name: String,
196    pub affected_nodes: usize,
197    pub affected_edges: usize,
198    pub actor_id: Option<String>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
202pub struct IngestTableOutput {
203    pub table_key: String,
204    pub rows_loaded: usize,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
208pub struct IngestOutput {
209    pub uri: String,
210    pub branch: String,
211    pub base_branch: String,
212    pub branch_created: bool,
213    #[schema(value_type = LoadModeSchema)]
214    pub mode: LoadMode,
215    pub tables: Vec<IngestTableOutput>,
216    pub actor_id: Option<String>,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
220pub struct CommitOutput {
221    pub graph_commit_id: String,
222    pub manifest_branch: Option<String>,
223    pub manifest_version: u64,
224    pub parent_commit_id: Option<String>,
225    pub merged_parent_commit_id: Option<String>,
226    pub actor_id: Option<String>,
227    /// Commit creation time as Unix epoch microseconds.
228    #[schema(example = 1714000000000000i64)]
229    pub created_at: i64,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
233pub struct CommitListOutput {
234    pub commits: Vec<CommitOutput>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
238pub struct ReadRequest {
239    /// GQ query source. May declare one or more named queries; pick one with
240    /// `query_name` if there is more than one.
241    #[schema(
242        example = "query get_person($name: String) {\n    match {\n        $p: Person { name: $name }\n    }\n    return { $p.name, $p.age }\n}"
243    )]
244    pub query_source: String,
245    /// Name of the query to run when `query_source` declares multiple. Optional
246    /// when only one query is declared.
247    pub query_name: Option<String>,
248    /// JSON object whose keys match the query's declared parameters.
249    pub params: Option<Value>,
250    /// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`.
251    pub branch: Option<String>,
252    /// Snapshot id to read from. Mutually exclusive with `branch`.
253    pub snapshot: Option<String>,
254}
255
256/// Inline read-query request for `POST /query`.
257///
258/// Friendlier-named alternative to [`ReadRequest`] for ad-hoc reads and
259/// AI-agent integration. Mutations are rejected with 400 — use `POST
260/// /mutate` (or its deprecated alias `POST /change`) for write queries.
261/// Field names are deliberately short (`query`, `name`) to match the GQ
262/// keyword and the CLI `-e` flag.
263#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
264pub struct QueryRequest {
265    /// GQ read-query source. May declare one or more named queries; pick one
266    /// with `name` when more than one is declared. Mutations
267    /// (`insert`/`update`/`delete`) get 400 — use `POST /mutate` (or its
268    /// deprecated alias `POST /change`) instead.
269    #[schema(example = "query get_person($name: String) {\n    match {\n        $p: Person { name: $name }\n    }\n    return { $p.name, $p.age }\n}")]
270    pub query: String,
271    /// Name of the query to run when `query` declares multiple. Optional when
272    /// only one query is declared.
273    pub name: Option<String>,
274    /// JSON object whose keys match the query's declared parameters.
275    pub params: Option<Value>,
276    /// Branch to read from. Mutually exclusive with `snapshot`. Defaults to `main`.
277    pub branch: Option<String>,
278    /// Snapshot id to read from. Mutually exclusive with `branch`.
279    pub snapshot: Option<String>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
283pub struct ChangeRequest {
284    /// GQ mutation source containing `insert`, `update`, or `delete` statements.
285    /// May declare multiple named mutations; pick one with `name`.
286    ///
287    /// Accepts the legacy field name `query_source` as a deserialization alias.
288    #[schema(
289        example = "query insert_person($name: String, $age: I32) {\n    insert Person { name: $name, age: $age }\n}"
290    )]
291    #[serde(alias = "query_source")]
292    pub query: String,
293    /// Name of the mutation to run when `query` declares multiple.
294    ///
295    /// Accepts the legacy field name `query_name` as a deserialization alias.
296    #[serde(default, alias = "query_name")]
297    pub name: Option<String>,
298    /// JSON object whose keys match the mutation's declared parameters.
299    #[serde(default)]
300    pub params: Option<Value>,
301    /// Target branch. Defaults to `main`.
302    #[serde(default)]
303    pub branch: Option<String>,
304}
305
306/// Body for `POST /queries/{name}` — invokes the server-side stored query
307/// named in the path. The query source and name come from the registry,
308/// never the body; only the runtime inputs are supplied here.
309#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
310pub struct InvokeStoredQueryRequest {
311    /// JSON object whose keys match the stored query's declared parameters.
312    #[serde(default)]
313    pub params: Option<Value>,
314    /// Branch to run against. Defaults to `main`; for a stored mutation the
315    /// write targets this branch.
316    #[serde(default)]
317    pub branch: Option<String>,
318    /// Snapshot id to read from (read queries only — rejected for a stored
319    /// mutation). Mutually exclusive with `branch`.
320    #[serde(default)]
321    pub snapshot: Option<String>,
322}
323
324/// Response for `POST /queries/{name}`: the read envelope for a stored
325/// read, or the mutation envelope for a stored mutation. Serialized
326/// **untagged**, so the wire shape is exactly [`ReadOutput`] or
327/// [`ChangeOutput`] — classification follows the stored query, not a
328/// wrapper field.
329#[derive(Debug, Serialize, ToSchema)]
330#[serde(untagged)]
331pub enum InvokeStoredQueryResponse {
332    Read(ReadOutput),
333    Change(ChangeOutput),
334}
335
336/// The kind of a stored-query parameter, decomposed so a client (e.g. an
337/// MCP server) can build a typed input schema with a closed `match` and
338/// never re-parse omnigraph's type spelling. `bigint`/`date`/`datetime`/
339/// `blob` are carried as JSON strings on the wire: a 64-bit integer past
340/// 2^53 loses precision as a JSON number, and Date/DateTime are ISO
341/// strings, Blob a blob-URI string.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
343#[serde(rename_all = "snake_case")]
344pub enum ParamKind {
345    String,
346    Bool,
347    Int,
348    #[serde(rename = "bigint")]
349    BigInt,
350    Float,
351    Date,
352    #[serde(rename = "datetime")]
353    DateTime,
354    Blob,
355    Vector,
356    List,
357}
358
359/// One declared parameter of a stored query, projected for the catalog.
360#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
361pub struct ParamDescriptor {
362    pub name: String,
363    pub kind: ParamKind,
364    /// Element kind when `kind == list` (always a scalar — the grammar
365    /// forbids lists of vectors or nested lists).
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub item_kind: Option<ParamKind>,
368    /// Dimension when `kind == vector`.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub vector_dim: Option<u32>,
371    /// `false` → the caller must supply it; `true` → optional.
372    pub nullable: bool,
373}
374
375/// One entry in the stored-query catalog (`GET /queries`).
376#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
377pub struct QueryCatalogEntry {
378    /// Registry key / invoke path segment (`POST /queries/{name}`).
379    pub name: String,
380    /// MCP tool id (the `tool_name` override, else `name`).
381    pub tool_name: String,
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub description: Option<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub instruction: Option<String>,
386    /// `true` for a stored mutation → an MCP read-only hint of `false`.
387    pub mutation: bool,
388    pub params: Vec<ParamDescriptor>,
389}
390
391/// Response for `GET /queries`: the `mcp.expose` subset of a graph's
392/// stored-query registry, each with typed parameters.
393#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
394pub struct QueriesCatalogOutput {
395    pub queries: Vec<QueryCatalogEntry>,
396}
397
398/// Total map from a resolved scalar to its catalog kind. Exhaustive on
399/// purpose: a new `ScalarType` is a compile error here until catalogued.
400fn scalar_kind(scalar: ScalarType) -> ParamKind {
401    match scalar {
402        ScalarType::String => ParamKind::String,
403        ScalarType::Bool => ParamKind::Bool,
404        ScalarType::I32 | ScalarType::U32 => ParamKind::Int,
405        ScalarType::I64 | ScalarType::U64 => ParamKind::BigInt,
406        ScalarType::F32 | ScalarType::F64 => ParamKind::Float,
407        ScalarType::Date => ParamKind::Date,
408        ScalarType::DateTime => ParamKind::DateTime,
409        ScalarType::Blob => ParamKind::Blob,
410        ScalarType::Vector(_) => ParamKind::Vector,
411    }
412}
413
414fn param_descriptor(param: &Param) -> ParamDescriptor {
415    match PropType::from_param_type_name(&param.type_name, param.nullable) {
416        Some(pt) if pt.list => ParamDescriptor {
417            name: param.name.clone(),
418            kind: ParamKind::List,
419            item_kind: Some(scalar_kind(pt.scalar)),
420            vector_dim: None,
421            nullable: param.nullable,
422        },
423        Some(pt) => {
424            let (kind, vector_dim) = match pt.scalar {
425                ScalarType::Vector(dim) => (ParamKind::Vector, Some(dim)),
426                other => (scalar_kind(other), None),
427            };
428            ParamDescriptor {
429                name: param.name.clone(),
430                kind,
431                item_kind: None,
432                vector_dim,
433                nullable: param.nullable,
434            }
435        }
436        // Unreachable for a parsed query (every declared param type is
437        // grammatical); fall back to an opaque string so the field is still
438        // usable rather than dropped.
439        None => ParamDescriptor {
440            name: param.name.clone(),
441            kind: ParamKind::String,
442            item_kind: None,
443            vector_dim: None,
444            nullable: param.nullable,
445        },
446    }
447}
448
449/// Project a loaded stored query into its catalog entry (typed params,
450/// MCP tool name, read/mutate flag, description/instruction).
451pub fn query_catalog_entry(query: &StoredQuery) -> QueryCatalogEntry {
452    QueryCatalogEntry {
453        name: query.name.clone(),
454        tool_name: query.effective_tool_name().to_string(),
455        description: query.decl.description.clone(),
456        instruction: query.decl.instruction.clone(),
457        mutation: query.is_mutation(),
458        params: query.decl.params.iter().map(param_descriptor).collect(),
459    }
460}
461
462#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
463pub struct SchemaApplyRequest {
464    /// Project schema in `.pg` source form. The diff against the current
465    /// schema produces the migration steps that will be applied.
466    #[schema(
467        example = "node Person {\n    name: String @key\n    age: I32?\n}\n\nedge Knows: Person -> Person"
468    )]
469    pub schema_source: String,
470    /// When true, promote every `DropMode::Soft` step in the plan to
471    /// `DropMode::Hard`, making the prior column data unreachable
472    /// after the apply. Matches the CLI's `--allow-data-loss` flag.
473    /// Defaults to `false` (drops remain reversible via time travel).
474    #[serde(default)]
475    pub allow_data_loss: bool,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
479pub struct SchemaApplyOutput {
480    pub uri: String,
481    pub supported: bool,
482    pub applied: bool,
483    pub step_count: usize,
484    pub manifest_version: u64,
485    #[schema(value_type = Vec<Value>)]
486    pub steps: Vec<SchemaMigrationStep>,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
490pub struct SchemaOutput {
491    pub schema_source: String,
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
495pub struct IngestRequest {
496    /// Target branch. Created from `from` if it does not yet exist. Defaults to `main`.
497    pub branch: Option<String>,
498    /// Parent branch used to create `branch` if it does not exist. Defaults to `main`.
499    pub from: Option<String>,
500    /// How existing rows are handled. Defaults to `merge`.
501    #[schema(value_type = Option<LoadModeSchema>)]
502    pub mode: Option<LoadMode>,
503    /// NDJSON payload: one record per line, each shaped
504    /// `{"type": "<TypeName>", "data": {...}}`.
505    #[schema(
506        example = "{\"type\": \"Person\", \"data\": {\"name\": \"Alice\", \"age\": 30}}\n{\"type\": \"Person\", \"data\": {\"name\": \"Bob\", \"age\": 25}}"
507    )]
508    pub data: String,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
512pub struct ExportRequest {
513    /// Branch to export. Defaults to `main`.
514    pub branch: Option<String>,
515    /// Restrict the export to these node/edge type names. Empty exports all types.
516    #[serde(default)]
517    pub type_names: Vec<String>,
518    /// Restrict the export to these table keys. Empty exports all tables.
519    #[serde(default)]
520    pub table_keys: Vec<String>,
521}
522
523#[derive(Debug, Clone, Deserialize, IntoParams)]
524pub struct SnapshotQuery {
525    pub branch: Option<String>,
526}
527
528#[derive(Debug, Clone, Deserialize, IntoParams)]
529pub struct CommitListQuery {
530    pub branch: Option<String>,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
534pub struct HealthOutput {
535    pub status: String,
536    pub version: String,
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub source_version: Option<String>,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
542#[serde(rename_all = "snake_case")]
543pub enum ErrorCode {
544    Unauthorized,
545    Forbidden,
546    BadRequest,
547    NotFound,
548    /// 405 Method Not Allowed — the route exists but the active server
549    /// mode doesn't serve this method (e.g. `GET /graphs` in single-graph
550    /// mode). Distinct from 404 so clients can tell "wrong context" from
551    /// "no such resource."
552    MethodNotAllowed,
553    Conflict,
554    /// 429 Too Many Requests — per-actor admission cap exceeded.
555    /// Clients should respect the `Retry-After` header.
556    TooManyRequests,
557    Internal,
558}
559
560/// Structured details for a publisher-level OCC failure. Surfaces alongside
561/// HTTP 409 when a write was rejected because the caller's pre-write view of
562/// one table's manifest version was stale relative to the current head. The
563/// expected/actual fields tell the client which table to refresh.
564#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
565pub struct ManifestConflictOutput {
566    pub table_key: String,
567    pub expected: u64,
568    pub actual: u64,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
572pub struct ErrorOutput {
573    pub error: String,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub code: Option<ErrorCode>,
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub merge_conflicts: Vec<MergeConflictOutput>,
578    /// Set when the conflict is a publisher CAS rejection
579    /// (`ManifestConflictDetails::ExpectedVersionMismatch`). The caller's
580    /// pre-write view of `table_key` was at version `expected` but the
581    /// manifest is now at `actual`. Refresh and retry.
582    #[serde(skip_serializing_if = "Option::is_none")]
583    pub manifest_conflict: Option<ManifestConflictOutput>,
584}
585
586pub fn snapshot_payload(branch: &str, snapshot: &Snapshot) -> SnapshotOutput {
587    let mut entries: Vec<_> = snapshot.entries().cloned().collect();
588    entries.sort_by(|a, b| a.table_key.cmp(&b.table_key));
589    let tables = entries
590        .iter()
591        .map(|entry| SnapshotTableOutput {
592            table_key: entry.table_key.clone(),
593            table_path: entry.table_path.clone(),
594            table_version: entry.table_version,
595            table_branch: entry.table_branch.clone(),
596            row_count: entry.row_count,
597        })
598        .collect::<Vec<_>>();
599    SnapshotOutput {
600        branch: branch.to_string(),
601        manifest_version: snapshot.version(),
602        tables,
603    }
604}
605
606pub fn schema_apply_output(uri: &str, result: SchemaApplyResult) -> SchemaApplyOutput {
607    SchemaApplyOutput {
608        uri: uri.to_string(),
609        supported: result.supported,
610        applied: result.applied,
611        step_count: result.steps.len(),
612        manifest_version: result.manifest_version,
613        steps: result.steps,
614    }
615}
616
617pub fn commit_output(commit: &GraphCommit) -> CommitOutput {
618    CommitOutput {
619        graph_commit_id: commit.graph_commit_id.clone(),
620        manifest_branch: commit.manifest_branch.clone(),
621        manifest_version: commit.manifest_version,
622        parent_commit_id: commit.parent_commit_id.clone(),
623        merged_parent_commit_id: commit.merged_parent_commit_id.clone(),
624        actor_id: commit.actor_id.clone(),
625        created_at: commit.created_at,
626    }
627}
628
629pub fn read_output(query_name: String, target: &ReadTarget, result: QueryResult) -> ReadOutput {
630    let columns = result
631        .schema()
632        .fields()
633        .iter()
634        .map(|field| field.name().clone())
635        .collect();
636    ReadOutput {
637        query_name,
638        target: read_target_output(target),
639        row_count: result.num_rows(),
640        columns,
641        rows: result.to_rust_json(),
642    }
643}
644
645pub fn ingest_output(uri: &str, result: &IngestResult, actor_id: Option<String>) -> IngestOutput {
646    IngestOutput {
647        uri: uri.to_string(),
648        branch: result.branch.clone(),
649        base_branch: result.base_branch.clone(),
650        branch_created: result.branch_created,
651        mode: result.mode,
652        tables: result
653            .tables
654            .iter()
655            .map(|table| IngestTableOutput {
656                table_key: table.table_key.clone(),
657                rows_loaded: table.rows_loaded,
658            })
659            .collect(),
660        actor_id,
661    }
662}
663
664pub fn read_target_output(target: &ReadTarget) -> ReadTargetOutput {
665    match target {
666        ReadTarget::Branch(branch) => ReadTargetOutput {
667            branch: Some(branch.clone()),
668            snapshot: None,
669        },
670        ReadTarget::Snapshot(snapshot) => ReadTargetOutput {
671            branch: None,
672            snapshot: Some(snapshot.as_str().to_string()),
673        },
674    }
675}
676
677// ─── MR-668 — management endpoint shapes ──────────────────────────────────
678
679/// One entry in the response from `GET /graphs`. Cluster operators
680/// consume this list to discover which graphs the server is currently
681/// serving. The shape is intentionally minimal — `graph_id` and `uri`
682/// are the only fields a routing client needs.
683#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
684pub struct GraphInfo {
685    pub graph_id: String,
686    pub uri: String,
687}
688
689/// Response from `GET /graphs`. Lists every graph registered with the
690/// server in alphabetical order by `graph_id` (sorted server-side so
691/// clients get deterministic output across requests).
692#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
693pub struct GraphListResponse {
694    pub graphs: Vec<GraphInfo>,
695}