Skip to main content

unitycatalog_common/models/delta/
v1.rs

1//! Hand-written serde models for the UC Delta REST API (`/delta/v1/...`).
2//!
3//! These mirror the component schemas in `openapi/delta.yaml` (vendored from the
4//! Unity Catalog Java reference, `api/delta.yaml`). The wire format is kebab-case
5//! JSON, so every struct uses `#[serde(rename_all = "kebab-case")]`; individual
6//! fields whose JSON key is not a straight kebab-case of the Rust identifier
7//! carry an explicit `#[serde(rename = "...")]`.
8//!
9//! This is a standalone REST protocol (not resource-CRUD), so — unlike most of
10//! the workspace — it is not generated from proto. The pure wire DTOs live here
11//! in `common` so both the server router (`crates/server`) and the client
12//! (`crates/client`) share a single definition; the server's `IntoResponse`
13//! error envelope stays in the server crate.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19// ===================================================================
20// Enums
21// ===================================================================
22
23/// The type of a Delta table.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
26pub enum DeltaTableType {
27    Managed,
28    External,
29}
30
31/// The data source format of a table, as carried on `DeltaCreateTableRequest`.
32///
33/// Mirrors the canonical Unity Catalog `DataSourceFormat` set. Serializes to the
34/// uppercase UC format strings (e.g. `"DELTA"`), matching what the Java OSS
35/// server's createTable handler expects. The Delta REST API only operates on
36/// Delta tables, so in practice this is always [`Delta`](Self::Delta); the wider
37/// set is kept so the field round-trips any value the catalog might report.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
40pub enum DeltaDataSourceFormat {
41    Delta,
42    Iceberg,
43    Hudi,
44    Parquet,
45    Csv,
46    Json,
47    Orc,
48    Avro,
49    Text,
50    UnityCatalog,
51    Deltasharing,
52}
53
54/// The permission level for a storage credential.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
57pub enum DeltaCredentialOperation {
58    Read,
59    ReadWrite,
60}
61
62// ===================================================================
63// Shared credential types
64// ===================================================================
65
66/// Cloud provider-specific credential configuration. Only keys for the relevant
67/// cloud provider are populated; the others are omitted (`None`).
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69pub struct DeltaStorageCredentialConfig {
70    #[serde(rename = "s3.access-key-id", skip_serializing_if = "Option::is_none")]
71    pub s3_access_key_id: Option<String>,
72    #[serde(
73        rename = "s3.secret-access-key",
74        skip_serializing_if = "Option::is_none"
75    )]
76    pub s3_secret_access_key: Option<String>,
77    #[serde(rename = "s3.session-token", skip_serializing_if = "Option::is_none")]
78    pub s3_session_token: Option<String>,
79    #[serde(rename = "azure.sas-token", skip_serializing_if = "Option::is_none")]
80    pub azure_sas_token: Option<String>,
81    #[serde(rename = "gcs.oauth-token", skip_serializing_if = "Option::is_none")]
82    pub gcs_oauth_token: Option<String>,
83}
84
85/// Temporary storage credential with prefix and config.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub struct DeltaStorageCredential {
89    /// Storage path prefix this credential applies to.
90    pub prefix: String,
91    pub operation: DeltaCredentialOperation,
92    pub config: DeltaStorageCredentialConfig,
93    /// Credential expiration time in epoch milliseconds.
94    pub expiration_time_ms: i64,
95}
96
97/// Response carrying temporary cloud storage credentials.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "kebab-case")]
100pub struct DeltaCredentialsResponse {
101    pub storage_credentials: Vec<DeltaStorageCredential>,
102}
103
104// ===================================================================
105// Configuration
106// ===================================================================
107
108/// Catalog configuration and supported endpoints.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "kebab-case")]
111pub struct DeltaCatalogConfig {
112    /// List of supported endpoints.
113    pub endpoints: Vec<String>,
114    /// The negotiated protocol version (e.g., "1.0").
115    pub protocol_version: String,
116}
117
118// ===================================================================
119// Delta data types (schema)
120// ===================================================================
121
122/// A Delta column data type.
123///
124/// On the wire, primitive types are bare JSON strings (e.g. `"long"`, `"string"`,
125/// `"decimal(10,2)"`) while complex types are JSON objects carrying a `type`
126/// discriminator (`"array"`, `"map"`, `"struct"`, `"decimal"`). serde's `untagged`
127/// representation drives the split: a JSON string matches [`DeltaDataType::Primitive`];
128/// a JSON object matches one of the object variants. Each object variant carries its
129/// own `type` constant so it both round-trips and self-identifies.
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
131#[serde(untagged)]
132pub enum DeltaDataType {
133    /// Bare-string primitive type, e.g. `"long"`, `"string"`, `"decimal(10,2)"`.
134    Primitive(String),
135    Array(Box<DeltaArrayType>),
136    Map(Box<DeltaMapType>),
137    Struct(Box<DeltaStructType>),
138    /// Object form of decimal (`{"type":"decimal","precision":..,"scale":..}`).
139    /// The string form `"decimal(p,s)"` is carried by [`DeltaDataType::Primitive`].
140    Decimal(DeltaDecimalType),
141}
142
143/// The `type` discriminator constant for an array data type.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum ArrayTypeTag {
147    #[default]
148    Array,
149}
150
151/// The `type` discriminator constant for a map data type.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
153#[serde(rename_all = "lowercase")]
154pub enum MapTypeTag {
155    #[default]
156    Map,
157}
158
159/// The `type` discriminator constant for a decimal data type.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
161#[serde(rename_all = "lowercase")]
162pub enum DecimalTypeTag {
163    #[default]
164    Decimal,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(rename_all = "kebab-case")]
169pub struct DeltaArrayType {
170    #[serde(rename = "type", default)]
171    pub type_tag: ArrayTypeTag,
172    pub element_type: DeltaDataType,
173    pub contains_null: bool,
174}
175
176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
177#[serde(rename_all = "kebab-case")]
178pub struct DeltaMapType {
179    #[serde(rename = "type", default)]
180    pub type_tag: MapTypeTag,
181    pub key_type: DeltaDataType,
182    pub value_type: DeltaDataType,
183    pub value_contains_null: bool,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct DeltaDecimalType {
188    #[serde(rename = "type", default)]
189    pub type_tag: DecimalTypeTag,
190    pub precision: i32,
191    pub scale: i32,
192}
193
194/// A field in a [`DeltaStructType`]: name, type, nullability, and metadata.
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct DeltaStructField {
197    pub name: String,
198    #[serde(rename = "type")]
199    pub data_type: DeltaDataType,
200    pub nullable: bool,
201    /// Arbitrary column metadata (e.g. `comment`, `delta.columnMapping.id`).
202    /// Values can be strings, numbers, booleans, or nested objects.
203    pub metadata: BTreeMap<String, serde_json::Value>,
204}
205
206/// Struct type containing named fields. Carries the `type: "struct"`
207/// discriminator on the wire (it is the schema container for tables), so it is
208/// emitted/accepted explicitly even when used as a bare `columns` value.
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
210pub struct DeltaStructType {
211    #[serde(rename = "type", default = "struct_type_tag")]
212    pub type_tag: StructTypeTag,
213    pub fields: Vec<DeltaStructField>,
214}
215
216fn struct_type_tag() -> StructTypeTag {
217    StructTypeTag::Struct
218}
219
220/// The constant `"struct"` discriminator for [`DeltaStructType`].
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
222#[serde(rename_all = "lowercase")]
223pub enum StructTypeTag {
224    #[default]
225    Struct,
226}
227
228// ===================================================================
229// Protocol
230// ===================================================================
231
232/// Delta table protocol specification.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "kebab-case")]
235pub struct DeltaProtocol {
236    pub min_reader_version: i32,
237    pub min_writer_version: i32,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub reader_features: Option<Vec<String>>,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub writer_features: Option<Vec<String>>,
242}
243
244/// Suggested protocol advertised in the staging response (no version fields,
245/// only feature hints).
246#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "kebab-case")]
248pub struct DeltaSuggestedProtocol {
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub reader_features: Option<Vec<String>>,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub writer_features: Option<Vec<String>>,
253}
254
255// ===================================================================
256// Domain metadata
257// ===================================================================
258
259/// Configuration for Clustered Tables.
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct DeltaClusteringDomainMetadata {
262    /// Clustering column paths. Each inner array is a path from root schema to a
263    /// column.
264    #[serde(rename = "clusteringColumns")]
265    pub clustering_columns: Vec<Vec<String>>,
266}
267
268/// Configuration for Row Tracking.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct DeltaRowTrackingDomainMetadata {
271    /// The highest fresh Row ID assigned so far.
272    #[serde(rename = "rowIdHighWaterMark")]
273    pub row_id_high_water_mark: i64,
274}
275
276/// Domain metadata entries keyed by domain name.
277#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
278pub struct DeltaDomainMetadataUpdates {
279    #[serde(rename = "delta.clustering", skip_serializing_if = "Option::is_none")]
280    pub delta_clustering: Option<DeltaClusteringDomainMetadata>,
281    #[serde(rename = "delta.rowTracking", skip_serializing_if = "Option::is_none")]
282    pub delta_row_tracking: Option<DeltaRowTrackingDomainMetadata>,
283}
284
285// ===================================================================
286// Uniform / Iceberg
287// ===================================================================
288
289/// Iceberg-specific conversion metadata.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "kebab-case")]
292pub struct DeltaIcebergMetadata {
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub metadata_location: Option<String>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub converted_delta_version: Option<i64>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub converted_delta_timestamp: Option<i64>,
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub base_converted_delta_version: Option<i64>,
301}
302
303/// UniForm conversion metadata. Present only for Uniform (Delta + Iceberg) tables.
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
305pub struct DeltaUniformMetadata {
306    pub iceberg: DeltaIcebergMetadata,
307}
308
309// ===================================================================
310// Commit
311// ===================================================================
312
313/// Delta commit information for CCv2.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(rename_all = "kebab-case")]
316pub struct DeltaCommit {
317    pub version: i64,
318    /// In-commit timestamp, in epoch milliseconds.
319    pub timestamp: i64,
320    /// UUID-based commit file name.
321    pub file_name: String,
322    pub file_size: i64,
323    /// File modification timestamp, in epoch milliseconds.
324    pub file_modification_timestamp: i64,
325}
326
327// ===================================================================
328// Table lifecycle
329// ===================================================================
330
331/// Request to create a staging table.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct DeltaCreateStagingTableRequest {
334    pub name: String,
335}
336
337/// Response from creating a staging table. Advertises the UC catalog-managed
338/// contract (required / suggested protocol + properties) plus READ_WRITE
339/// credentials for writing the initial commit.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341#[serde(rename_all = "kebab-case")]
342pub struct DeltaStagingTableResponse {
343    pub table_id: String,
344    pub table_type: DeltaTableType,
345    pub location: String,
346    pub storage_credentials: Vec<DeltaStorageCredential>,
347    pub required_protocol: DeltaProtocol,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub suggested_protocol: Option<DeltaSuggestedProtocol>,
350    /// Properties that must be set; null values mean any valid value is allowed.
351    pub required_properties: BTreeMap<String, Option<String>>,
352    /// Properties that should be set whenever possible; null values mean the
353    /// client generates the value.
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub suggested_properties: Option<BTreeMap<String, Option<String>>>,
356}
357
358/// Request to create a Delta table (managed or external).
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360#[serde(rename_all = "kebab-case")]
361pub struct DeltaCreateTableRequest {
362    pub name: String,
363    pub location: String,
364    pub table_type: DeltaTableType,
365    /// Data source format — `"DELTA"` for Delta tables. Required by the running
366    /// Java UC OSS server (`ghcr.io/roeap/unitycatalog:v0.0.0-dev-3`), whose
367    /// createTable handler reads it from the request body. (Newer server code
368    /// hardcodes `DELTA` in `DeltaCreateTableMapper` and ignores this field, and
369    /// the `delta.yaml` schema omits it — a spec/impl drift; sending it satisfies
370    /// both.)
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub data_source_format: Option<DeltaDataSourceFormat>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub comment: Option<String>,
375    pub columns: DeltaStructType,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub partition_columns: Option<Vec<String>>,
378    pub protocol: DeltaProtocol,
379    pub properties: BTreeMap<String, String>,
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub domain_metadata: Option<DeltaDomainMetadataUpdates>,
382    /// Timestamp of version 0 (the commit the client wrote before calling this
383    /// endpoint), in epoch milliseconds.
384    pub last_commit_timestamp_ms: i64,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub uniform: Option<DeltaUniformMetadata>,
387}
388
389/// Complete table metadata.
390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
391#[serde(rename_all = "kebab-case")]
392pub struct DeltaTableMetadata {
393    /// Entity tag for optimistic concurrency control.
394    pub etag: String,
395    pub table_type: DeltaTableType,
396    pub table_uuid: String,
397    pub location: String,
398    pub created_time: i64,
399    pub updated_time: i64,
400    pub columns: DeltaStructType,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub partition_columns: Option<Vec<String>>,
403    pub properties: BTreeMap<String, String>,
404    /// The version of the last commit that changed table metadata
405    /// (`delta.lastUpdateVersion`).
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub last_commit_version: Option<i64>,
408    /// Timestamp of the last commit that changed table metadata, in epoch
409    /// milliseconds (`delta.lastCommitTimestamp`).
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub last_commit_timestamp_ms: Option<i64>,
412}
413
414/// Response from `loadTable` / `createTable` / `updateTable`.
415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
416#[serde(rename_all = "kebab-case")]
417pub struct DeltaLoadTableResponse {
418    pub metadata: DeltaTableMetadata,
419    /// All unbackfilled CCv2 commits.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub commits: Option<Vec<DeltaCommit>>,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub uniform: Option<DeltaUniformMetadata>,
424    /// The latest ratified table version tracked by the server, including
425    /// data-only commits.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub latest_table_version: Option<i64>,
428}
429
430/// Request to rename a table within the same catalog and schema.
431#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
432#[serde(rename_all = "kebab-case")]
433pub struct DeltaRenameTableRequest {
434    pub new_name: String,
435}
436
437// ===================================================================
438// Update table (requirements + actions)
439// ===================================================================
440
441/// A pre-condition that must hold for the update to apply.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(tag = "type", rename_all = "kebab-case")]
444pub enum DeltaTableRequirement {
445    /// `assert-table-uuid`: the table UUID must match `uuid`.
446    AssertTableUuid { uuid: String },
447    /// `assert-etag`: the table etag must match `etag`.
448    AssertEtag { etag: String },
449}
450
451/// A single update action. Tagged by the `action` discriminator, matching the
452/// `propertyName: action` + `const` mapping in `delta.yaml`.
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
454#[serde(tag = "action", rename_all = "kebab-case")]
455pub enum DeltaTableUpdate {
456    SetProperties {
457        updates: BTreeMap<String, String>,
458    },
459    RemoveProperties {
460        removals: Vec<String>,
461    },
462    SetColumns {
463        columns: DeltaStructType,
464    },
465    SetTableComment {
466        comment: String,
467    },
468    AddCommit {
469        commit: DeltaCommit,
470        #[serde(skip_serializing_if = "Option::is_none")]
471        uniform: Option<DeltaUniformMetadata>,
472    },
473    SetLatestBackfilledVersion {
474        #[serde(rename = "latest-published-version")]
475        latest_published_version: i64,
476    },
477    SetProtocol {
478        protocol: DeltaProtocol,
479    },
480    SetDomainMetadata {
481        updates: DeltaDomainMetadataUpdates,
482    },
483    RemoveDomainMetadata {
484        domains: Vec<String>,
485    },
486    SetPartitionColumns {
487        #[serde(rename = "partition-columns")]
488        partition_columns: Vec<String>,
489    },
490    UpdateMetadataSnapshotVersion {
491        #[serde(rename = "last-commit-version")]
492        last_commit_version: i64,
493        #[serde(rename = "last-commit-timestamp-ms")]
494        last_commit_timestamp_ms: i64,
495    },
496}
497
498/// Request to update a table with requirements and updates.
499#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
500pub struct DeltaUpdateTableRequest {
501    pub requirements: Vec<DeltaTableRequirement>,
502    pub updates: Vec<DeltaTableUpdate>,
503}
504
505// ===================================================================
506// Metrics
507// ===================================================================
508
509/// Histogram tracking file counts and total bytes across size ranges.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "kebab-case")]
512pub struct DeltaFileSizeHistogram {
513    pub sorted_bin_boundaries: Vec<i64>,
514    pub file_counts: Vec<i64>,
515    pub total_bytes: Vec<i64>,
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub commit_version: Option<i64>,
518}
519
520/// Commit report metrics.
521#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(rename_all = "kebab-case")]
523pub struct DeltaCommitReport {
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub num_files_added: Option<i64>,
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub num_bytes_added: Option<i64>,
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub num_files_removed: Option<i64>,
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub num_bytes_removed: Option<i64>,
532    #[serde(skip_serializing_if = "Option::is_none")]
533    pub num_rows_inserted: Option<i64>,
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub num_rows_removed: Option<i64>,
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub num_rows_updated: Option<i64>,
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub file_size_histogram: Option<DeltaFileSizeHistogram>,
540}
541
542/// The metrics being reported.
543#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(rename_all = "kebab-case")]
545pub struct DeltaReport {
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub commit_report: Option<DeltaCommitReport>,
548}
549
550/// Request to report commit metrics (telemetry) for a table.
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "kebab-case")]
553pub struct DeltaReportMetricsRequest {
554    /// Table UUID. Must match the table identified by the path.
555    pub table_id: String,
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub report: Option<DeltaReport>,
558}
559
560// ===================================================================
561// Errors (the /delta/v1 envelope)
562// ===================================================================
563
564/// Error type identifier returned in Delta API error responses. Mirrors the
565/// `DeltaErrorType` enum in `delta.yaml`.
566#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
567pub enum DeltaErrorType {
568    BadRequestException,
569    InvalidParameterValueException,
570    UnsupportedTableFormatException,
571    NotAuthorizedException,
572    PermissionDeniedException,
573    NotFoundException,
574    NoSuchCatalogException,
575    NoSuchSchemaException,
576    NoSuchTableException,
577    AlreadyExistsException,
578    CommitVersionConflictException,
579    UpdateRequirementConflictException,
580    ResourceExhaustedException,
581    TooManyRequestsException,
582    CommitStateUnknownException,
583    InternalServerErrorException,
584    NotImplementedException,
585}
586
587impl DeltaErrorType {
588    /// Whether this error type denotes a missing resource (404 family).
589    pub fn is_not_found(&self) -> bool {
590        matches!(
591            self,
592            DeltaErrorType::NotFoundException
593                | DeltaErrorType::NoSuchCatalogException
594                | DeltaErrorType::NoSuchSchemaException
595                | DeltaErrorType::NoSuchTableException
596        )
597    }
598
599    /// Whether this error type denotes a name collision (`AlreadyExistsException`).
600    pub fn is_already_exists(&self) -> bool {
601        matches!(self, DeltaErrorType::AlreadyExistsException)
602    }
603
604    /// Whether this error type denotes a commit-version conflict that the client
605    /// should resolve by rebuilding its snapshot and retrying.
606    pub fn is_commit_conflict(&self) -> bool {
607        matches!(self, DeltaErrorType::CommitVersionConflictException)
608    }
609
610    /// Whether this error type denotes an unmet `assert-etag`/`assert-table-uuid`
611    /// requirement; the client should reload the table and retry.
612    pub fn is_update_requirement_conflict(&self) -> bool {
613        matches!(self, DeltaErrorType::UpdateRequirementConflictException)
614    }
615
616    /// Whether the request was throttled or hit a resource limit (429 family).
617    /// The client should back off (and, for `ResourceExhaustedException`,
618    /// backfill pending commits) before retrying.
619    pub fn is_resource_exhausted(&self) -> bool {
620        matches!(
621            self,
622            DeltaErrorType::ResourceExhaustedException | DeltaErrorType::TooManyRequestsException
623        )
624    }
625
626    /// Whether the commit outcome is unknown; the client must check table state
627    /// before retrying to avoid duplicate commits.
628    pub fn is_commit_state_unknown(&self) -> bool {
629        matches!(self, DeltaErrorType::CommitStateUnknownException)
630    }
631
632    /// Whether the table is not Delta, or is a Delta table this `/delta/v1`
633    /// endpoint does not support (`UnsupportedTableFormatException`, a 400). Per
634    /// the spec the client should fall back to the legacy UC table API.
635    pub fn is_unsupported_table_format(&self) -> bool {
636        matches!(self, DeltaErrorType::UnsupportedTableFormatException)
637    }
638
639    /// Whether the requested `/delta/v1` functionality is not supported by the
640    /// server (`NotImplementedException`, a 501). The client should fall back to
641    /// the legacy UC table API.
642    pub fn is_not_implemented(&self) -> bool {
643        matches!(self, DeltaErrorType::NotImplementedException)
644    }
645}
646
647/// The JSON error payload (`DeltaErrorModel` in `delta.yaml`).
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct DeltaErrorModel {
650    pub message: String,
651    #[serde(rename = "type")]
652    pub error_type: DeltaErrorType,
653    /// HTTP response code.
654    pub code: u16,
655    /// Stack trace, only populated when the server runs in debug mode.
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub stack: Option<Vec<String>>,
658}
659
660/// The JSON wrapper for all Delta API error responses (`DeltaErrorResponse`).
661#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct DeltaErrorResponse {
663    pub error: DeltaErrorModel,
664}
665
666#[cfg(test)]
667mod tests {
668    //! Round-trip tests over the example payloads in `openapi/delta.yaml`.
669    //!
670    //! Each test deserializes a spec example into our hand-written model and
671    //! re-serializes it, asserting the JSON survives a round-trip with the
672    //! kebab-case keys, enum casing, and tagged-union discriminators intact.
673    //! This is the Phase-1 parity gate: it pins our models to the vendored spec.
674
675    use super::*;
676    use serde_json::{Value, json};
677
678    /// Deserialize `json` into `T`, re-serialize, and assert structural equality
679    /// with the original JSON value (key order independent).
680    fn round_trip<T>(value: Value)
681    where
682        T: Serialize + for<'de> Deserialize<'de>,
683    {
684        let parsed: T = serde_json::from_value(value.clone())
685            .unwrap_or_else(|e| panic!("deserialize failed: {e}\njson: {value:#}"));
686        let reserialized = serde_json::to_value(&parsed).expect("serialize");
687        assert_eq!(
688            reserialized, value,
689            "round-trip mismatch\n  expected: {value:#}\n  got:      {reserialized:#}"
690        );
691    }
692
693    #[test]
694    fn staging_table_response() {
695        round_trip::<DeltaStagingTableResponse>(json!({
696            "table-id": "123e4567-e89b-12d3-a456-426614174000",
697            "table-type": "MANAGED",
698            "location": "s3://bucket/warehouse/catalog/schema/table",
699            "storage-credentials": [{
700                "prefix": "s3://bucket/warehouse/catalog/schema/table/",
701                "operation": "READ_WRITE",
702                "config": {
703                    "s3.access-key-id": "AK...example",
704                    "s3.secret-access-key": "ExampleKey",
705                    "s3.session-token": "token"
706                },
707                "expiration-time-ms": 1234567890000_i64
708            }],
709            "required-protocol": {
710                "min-reader-version": 3,
711                "min-writer-version": 7,
712                "reader-features": ["deletionVectors", "vacuumProtocolCheck"],
713                "writer-features": ["catalogManaged", "deletionVectors"]
714            },
715            "suggested-protocol": {
716                "reader-features": ["typeWidening"],
717                "writer-features": ["domainMetadata", "rowTracking"]
718            },
719            "required-properties": { "delta.checkpointPolicy": "v2" },
720            "suggested-properties": {
721                "delta.rowTracking.materializedRowIdColumnName": null,
722                "delta.rowTracking.materializedRowCommitVersionColumnName": null
723            }
724        }));
725    }
726
727    #[test]
728    fn create_table_request_with_decimal_and_partition() {
729        round_trip::<DeltaCreateTableRequest>(json!({
730            "name": "sales",
731            "location": "s3://bucket/warehouse/catalog/schema/sales",
732            "table-type": "MANAGED",
733            "columns": {
734                "type": "struct",
735                "fields": [
736                    { "name": "id", "type": "long", "nullable": false, "metadata": {} },
737                    {
738                        "name": "amount",
739                        "type": { "type": "decimal", "precision": 10, "scale": 2 },
740                        "nullable": true,
741                        "metadata": {}
742                    }
743                ]
744            },
745            "partition-columns": ["id"],
746            "protocol": {
747                "min-reader-version": 3,
748                "min-writer-version": 7,
749                "reader-features": ["deletionVectors"],
750                "writer-features": ["deletionVectors", "invariants"]
751            },
752            "properties": { "delta.enableDeletionVectors": "true" },
753            "last-commit-timestamp-ms": 1704067400000_i64
754        }));
755    }
756
757    #[test]
758    fn struct_type_carries_type_tag() {
759        // A bare `columns` value round-trips with its `type: "struct"` tag.
760        round_trip::<DeltaStructType>(json!({
761            "type": "struct",
762            "fields": [
763                { "name": "id", "type": "long", "nullable": false, "metadata": {} },
764                { "name": "name", "type": "string", "nullable": true, "metadata": {} }
765            ]
766        }));
767    }
768
769    #[test]
770    fn nested_array_and_map_types() {
771        round_trip::<DeltaStructField>(json!({
772            "name": "tags",
773            "type": {
774                "type": "array",
775                "element-type": "string",
776                "contains-null": true
777            },
778            "nullable": true,
779            "metadata": {}
780        }));
781        round_trip::<DeltaStructField>(json!({
782            "name": "props",
783            "type": {
784                "type": "map",
785                "key-type": "string",
786                "value-type": "long",
787                "value-contains-null": false
788            },
789            "nullable": true,
790            "metadata": {}
791        }));
792    }
793
794    #[test]
795    fn load_table_response_with_commits() {
796        round_trip::<DeltaLoadTableResponse>(json!({
797            "metadata": {
798                "etag": "etag-1",
799                "table-type": "MANAGED",
800                "table-uuid": "123e4567-e89b-12d3-a456-426614174000",
801                "location": "s3://bucket/warehouse/catalog/schema/table",
802                "created-time": 1705600000000_i64,
803                "updated-time": 1705600000000_i64,
804                "columns": { "type": "struct", "fields": [] },
805                "properties": { "delta.checkpointPolicy": "v2" },
806                "last-commit-version": 0,
807                "last-commit-timestamp-ms": 1704067400000_i64
808            },
809            "commits": [{
810                "version": 1,
811                "timestamp": 1704067200000_i64,
812                "file-name": "00000000-0000-0000-0000-00000000002a.json",
813                "file-size": 2048,
814                "file-modification-timestamp": 1704067200000_i64
815            }],
816            "latest-table-version": 1
817        }));
818    }
819
820    #[test]
821    fn update_table_request_add_commit() {
822        round_trip::<DeltaUpdateTableRequest>(json!({
823            "requirements": [
824                { "type": "assert-table-uuid", "uuid": "123e4567-e89b-12d3-a456-426614174000" },
825                { "type": "assert-etag", "etag": "etag-1" }
826            ],
827            "updates": [
828                {
829                    "action": "add-commit",
830                    "commit": {
831                        "version": 1,
832                        "timestamp": 1704067200000_i64,
833                        "file-name": "v.uuid1.json",
834                        "file-size": 2048,
835                        "file-modification-timestamp": 1704067200000_i64
836                    }
837                },
838                { "action": "set-latest-backfilled-version", "latest-published-version": 0 }
839            ]
840        }));
841    }
842
843    #[test]
844    fn update_table_request_metadata_actions() {
845        round_trip::<DeltaUpdateTableRequest>(json!({
846            "requirements": [],
847            "updates": [
848                { "action": "set-properties", "updates": { "k": "v" } },
849                { "action": "remove-properties", "removals": ["old"] },
850                { "action": "set-table-comment", "comment": "hello" },
851                {
852                    "action": "set-protocol",
853                    "protocol": { "min-reader-version": 3, "min-writer-version": 7 }
854                },
855                { "action": "set-partition-columns", "partition-columns": ["id"] },
856                {
857                    "action": "update-metadata-snapshot-version",
858                    "last-commit-version": 5,
859                    "last-commit-timestamp-ms": 1704067400000_i64
860                }
861            ]
862        }));
863    }
864
865    #[test]
866    fn update_table_domain_metadata_actions() {
867        round_trip::<DeltaUpdateTableRequest>(json!({
868            "requirements": [],
869            "updates": [
870                {
871                    "action": "set-domain-metadata",
872                    "updates": {
873                        "delta.clustering": { "clusteringColumns": [["id"], ["address", "city"]] },
874                        "delta.rowTracking": { "rowIdHighWaterMark": 42 }
875                    }
876                },
877                { "action": "remove-domain-metadata", "domains": ["delta.clustering"] }
878            ]
879        }));
880    }
881
882    #[test]
883    fn credentials_response() {
884        round_trip::<DeltaCredentialsResponse>(json!({
885            "storage-credentials": [{
886                "prefix": "s3://bucket/path/",
887                "operation": "READ",
888                "config": { "s3.access-key-id": "AK", "s3.secret-access-key": "SK" },
889                "expiration-time-ms": 1234567890000_i64
890            }]
891        }));
892    }
893
894    #[test]
895    fn catalog_config() {
896        round_trip::<DeltaCatalogConfig>(json!({
897            "endpoints": ["GET /v1/config"],
898            "protocol-version": "1.0"
899        }));
900    }
901
902    #[test]
903    fn report_metrics_request() {
904        round_trip::<DeltaReportMetricsRequest>(json!({
905            "table-id": "123e4567-e89b-12d3-a456-426614174000",
906            "report": {
907                "commit-report": {
908                    "num-files-added": 10,
909                    "num-bytes-added": 104857600_i64,
910                    "file-size-histogram": {
911                        "sorted-bin-boundaries": [0, 1024, 2048],
912                        "file-counts": [100, 40, 5],
913                        "total-bytes": [104857600_i64, 167772160_i64, 83886080_i64],
914                        "commit-version": 6
915                    }
916                }
917            }
918        }));
919    }
920
921    #[test]
922    fn error_response_round_trips() {
923        round_trip::<DeltaErrorResponse>(json!({
924            "error": {
925                "message": "table not found",
926                "type": "NoSuchTableException",
927                "code": 404
928            }
929        }));
930    }
931
932    #[test]
933    fn error_response_with_stack_round_trips() {
934        round_trip::<DeltaErrorResponse>(json!({
935            "error": {
936                "message": "boom",
937                "type": "InternalServerErrorException",
938                "code": 500,
939                "stack": ["frame one", "frame two"]
940            }
941        }));
942    }
943
944    #[test]
945    fn uniform_metadata_round_trips() {
946        round_trip::<DeltaUniformMetadata>(json!({
947            "iceberg": {
948                "metadata-location": "s3://bucket/metadata/v1.json",
949                "converted-delta-version": 42,
950                "converted-delta-timestamp": 1704067200000_i64
951            }
952        }));
953    }
954}