Skip to main content

omnigraph_api_types/
lib.rs

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