mz_deploy/client/models.rs
1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Domain models for Materialize catalog objects.
11//!
12//! These types represent objects in the Materialize system catalog and provide
13//! a type-safe interface over raw database rows.
14
15use chrono::{DateTime, Utc};
16use mz_sql_parser::ast::{CreateClusterStatement, Raw};
17use std::fmt;
18use std::str::FromStr;
19
20use crate::project::SchemaQualifier;
21
22/// The type of deployment - either tables-only or full objects.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum DeploymentKind {
26 /// Table creation deployment (apply tables command)
27 Tables,
28 /// Full object deployment (stage, apply commands)
29 Objects,
30 /// Contains sinks
31 Sinks,
32 /// Contains replacement materialized views (drop after apply, not swap)
33 Replacement,
34}
35
36impl fmt::Display for DeploymentKind {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 match self {
39 DeploymentKind::Tables => write!(f, "tables"),
40 DeploymentKind::Objects => write!(f, "objects"),
41 DeploymentKind::Sinks => write!(f, "sinks"),
42 DeploymentKind::Replacement => write!(f, "replacement"),
43 }
44 }
45}
46
47impl FromStr for DeploymentKind {
48 type Err = String;
49
50 fn from_str(s: &str) -> Result<Self, Self::Err> {
51 match s {
52 "tables" => Ok(DeploymentKind::Tables),
53 "objects" => Ok(DeploymentKind::Objects),
54 "sinks" => Ok(DeploymentKind::Sinks),
55 "replacement" => Ok(DeploymentKind::Replacement),
56 _ => Err(format!("Invalid deployment kind: {}", s)),
57 }
58 }
59}
60
61/// Deployment mode tag stored alongside each deployment.
62///
63/// Currently only `Stage` exists; kept as an enum for schema compatibility
64/// with the `mode` column in `_mz_deploy.tables.deployments`.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
66#[serde(rename_all = "snake_case")]
67pub enum DeploymentMode {
68 /// Full staging deployment that can be promoted to production.
69 Stage,
70}
71
72impl fmt::Display for DeploymentMode {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 match self {
75 DeploymentMode::Stage => write!(f, "stage"),
76 }
77 }
78}
79
80impl FromStr for DeploymentMode {
81 type Err = String;
82
83 fn from_str(s: &str) -> Result<Self, Self::Err> {
84 match s {
85 "stage" => Ok(DeploymentMode::Stage),
86 _ => Err(format!("Invalid deployment mode: {}", s)),
87 }
88 }
89}
90
91/// A compute cluster in Materialize.
92///
93/// Clusters provide the compute resources for materialized views, indexes, and sinks.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Cluster {
96 /// Materialize's unique identifier for the cluster
97 pub id: String,
98 /// Cluster name (e.g., "quickstart")
99 pub name: String,
100 /// Whether the cluster is managed
101 pub managed: bool,
102 /// Cluster size (e.g., "M.1-large"), None for unmanaged clusters
103 pub size: Option<String>,
104 /// Number of replicas for fault tolerance (stored as i64 to handle postgres uint4 type)
105 pub replication_factor: Option<i64>,
106}
107
108/// Configuration for a cluster replica (used for unmanaged clusters).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ClusterReplica {
111 /// Replica name (e.g., "r1", "r2")
112 pub name: String,
113 /// Replica size (e.g., "25cc")
114 pub size: String,
115 /// Optional availability zone
116 pub availability_zone: Option<String>,
117}
118
119/// A privilege grant on a cluster.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ObjectGrant {
122 /// Role name that receives the grant
123 pub grantee: String,
124 /// Privilege type (e.g., "USAGE", "CREATE")
125 pub privilege_type: String,
126}
127
128/// Configuration for creating a cluster (managed or unmanaged).
129///
130/// This captures all the information needed to clone a cluster's configuration
131/// including its replicas (for unmanaged clusters) and privilege grants.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ClusterConfig {
134 /// Managed cluster, captured as the canonical `CREATE CLUSTER` statement the
135 /// server renders for it. Cloning replays it under a new name.
136 Managed {
137 /// The production cluster's `CREATE CLUSTER` statement
138 create_stmt: CreateClusterStatement<Raw>,
139 /// Privilege grants on the cluster
140 grants: Vec<ObjectGrant>,
141 },
142 /// Unmanaged cluster with explicit replicas
143 Unmanaged {
144 /// Replica configurations
145 replicas: Vec<ClusterReplica>,
146 /// Privilege grants on the cluster
147 grants: Vec<ObjectGrant>,
148 },
149}
150
151impl ClusterConfig {
152 /// Get the grants for this cluster configuration.
153 pub fn grants(&self) -> &[ObjectGrant] {
154 match self {
155 ClusterConfig::Managed { grants, .. } => grants,
156 ClusterConfig::Unmanaged { grants, .. } => grants,
157 }
158 }
159}
160
161/// A schema deployment record tracking when and how a schema was deployed.
162///
163/// Stored in the `deploy.deployments` table. Schemas are deployed
164/// atomically - all objects in a dirty schema are redeployed together.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct SchemaDeploymentRecord {
167 /// Deploy ID (e.g., `"<init>"` for direct deploy, `"staging"` for staged deploy)
168 pub deploy_id: String,
169 /// Database name (e.g., "materialize")
170 pub database: String,
171 /// Schema name (e.g., "public")
172 pub schema: String,
173 /// When this schema was deployed
174 pub deployed_at: DateTime<Utc>,
175 /// Which Materialize user/role deployed this schema
176 pub deployed_by: String,
177 /// When this schema was promoted to production (NULL for staging, set on promotion)
178 pub promoted_at: Option<DateTime<Utc>>,
179 /// Git commit hash if available
180 pub git_commit: Option<String>,
181 /// Type of deployment (tables or objects)
182 pub kind: DeploymentKind,
183 /// Whether this is a stage or preview deployment
184 pub mode: DeploymentMode,
185}
186
187/// An object deployment record tracking object-level deployment history.
188///
189/// Stored in the `deploy.objects` table (append-only).
190/// Each row records that an object with a specific hash was deployed
191/// to a deployment at a point in time.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct DeploymentObjectRecord {
194 /// Deploy ID (e.g., `"<init>"` for direct deploy, `"staging"` for staged deploy)
195 pub deploy_id: String,
196 /// Database name (e.g., "materialize")
197 pub database: String,
198 /// Schema name (e.g., "public")
199 pub schema: String,
200 /// Object name (e.g., "my_view")
201 pub object: String,
202 /// Hash of the HIR DatabaseObject (semantic content hash)
203 pub object_hash: String,
204 /// When this object was deployed
205 pub deployed_at: DateTime<Utc>,
206}
207
208/// Metadata about a deployment.
209///
210/// Used for validation before operations like apply or abort.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct DeploymentMetadata {
213 /// Deploy ID
214 pub deploy_id: String,
215 /// When this deployment was promoted (NULL if not promoted)
216 pub promoted_at: Option<DateTime<Utc>>,
217 /// Whether this is a stage or preview deployment
218 pub mode: DeploymentMode,
219 /// List of (database, schema) tuples in this deployment
220 pub schemas: Vec<SchemaQualifier>,
221}
222
223/// A conflict record indicating a schema was updated after deployment started.
224///
225/// Used for git-merge-style conflict detection when promoting deployments.
226/// Returned by conflict detection queries that check if production schemas
227/// were modified since the staging deployment began.
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct ConflictRecord {
230 /// Database name containing the conflicting schema
231 pub database: String,
232 /// Schema name that has a conflict
233 pub schema: String,
234 /// Deploy ID that last promoted this schema
235 pub deploy_id: String,
236 /// When the schema was last promoted to production
237 pub promoted_at: DateTime<Utc>,
238}
239
240/// A cluster known to host at least one promoted deployment.
241///
242/// Returned by `list_production_clusters()` and used by `dev` to refuse
243/// overlay deployments that would land on production compute. Each record
244/// carries one representative protected deployment so the error message
245/// can explain why the cluster is considered production.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct ProductionClusterRecord {
248 /// Cluster name as it appears in `mz_clusters` (resolved from cluster_id).
249 pub cluster_name: String,
250 /// Database of one promoted deployment hosted on this cluster.
251 pub database: String,
252 /// Schema of one promoted deployment hosted on this cluster.
253 pub schema: String,
254 /// When that deployment was promoted.
255 pub promoted_at: DateTime<Utc>,
256}
257
258/// Details about a specific deployment.
259///
260/// Returned by `get_deployment_details()` for the describe command.
261#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
262pub struct DeploymentDetails {
263 /// When this deployment was created
264 pub deployed_at: DateTime<Utc>,
265 /// When this deployment was promoted (None if still staging)
266 pub promoted_at: Option<DateTime<Utc>>,
267 /// Which Materialize user/role deployed this
268 pub deployed_by: String,
269 /// Git commit hash if available
270 pub git_commit: Option<String>,
271 /// Type of deployment (tables or objects)
272 pub kind: DeploymentKind,
273 /// Whether this is a stage or preview deployment
274 pub mode: DeploymentMode,
275 /// List of (database, schema) tuples in this deployment
276 pub schemas: Vec<SchemaQualifier>,
277}
278
279/// Summary of a staging deployment.
280///
281/// Used by `list_staging_deployments()` for the deployments command.
282#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
283pub struct StagingDeployment {
284 /// When this deployment was created
285 pub deployed_at: DateTime<Utc>,
286 /// Which Materialize user/role deployed this
287 pub deployed_by: String,
288 /// Git commit hash if available
289 pub git_commit: Option<String>,
290 /// Type of deployment (tables or objects)
291 pub kind: DeploymentKind,
292 /// Whether this is a stage or preview deployment
293 pub mode: DeploymentMode,
294 /// List of (database, schema) tuples in this deployment
295 pub schemas: Vec<SchemaQualifier>,
296}
297
298/// A promoted deployment in history.
299///
300/// Returned by `list_deployment_history()` for the history command.
301#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
302pub struct DeploymentHistoryEntry {
303 /// Deploy ID for this deployment
304 pub deploy_id: String,
305 /// When this deployment was promoted
306 pub promoted_at: DateTime<Utc>,
307 /// Which Materialize user/role deployed this
308 pub deployed_by: String,
309 /// Git commit hash if available
310 pub git_commit: Option<String>,
311 /// Type of deployment (tables or objects)
312 pub kind: DeploymentKind,
313 /// List of (database, schema) tuples in this deployment
314 pub schemas: Vec<SchemaQualifier>,
315}
316
317/// State of an apply operation for resumable apply.
318///
319/// This is determined by checking the existence and comments of the
320/// `_mz_deploy.apply_<deploy_id>_pre` and `_mz_deploy.apply_<deploy_id>_post` schemas.
321/// Comments are set when creating the schemas; the swap transaction exchanges which
322/// schema has which comment.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum ApplyState {
325 /// No apply state schemas exist - fresh apply or completed.
326 NotStarted,
327 /// State schemas exist but swap hasn't happened yet.
328 /// The `_pre` schema has comment 'swapped=false'.
329 PreSwap,
330 /// Swap has completed.
331 /// After the swap, `_pre` schema has comment 'swapped=true' (it was `_post` before).
332 PostSwap,
333}
334
335/// A replacement materialized view record tracking the mapping between
336/// the replacement MV (in staging schema) and its production target.
337///
338/// The replacement MV lives in a staging schema (`target_schema` + staging suffix)
339/// within the same database, and has the same object name as the target.
340///
341/// Stored in `_mz_deploy.public.replacement_mvs` table.
342#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
343pub struct ReplacementMvRecord {
344 /// Deploy ID this replacement belongs to
345 pub deploy_id: String,
346 /// Database of the target (and replacement) MV
347 pub target_database: String,
348 /// Schema of the production target MV
349 pub target_schema: String,
350 /// Name of the target (and replacement) MV
351 pub target_name: String,
352 /// Schema of the replacement MV (staging) — typically `target_schema` + staging suffix
353 pub replacement_schema: String,
354}
355
356/// A pending statement to be executed after the swap.
357///
358/// Used for deferred execution of statements like sinks that cannot
359/// be created in staging (they write to external systems immediately).
360/// Stored in `_mz_deploy.public.pending_statements` table.
361#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
362pub struct PendingStatement {
363 /// Deploy ID this statement belongs to
364 pub deploy_id: String,
365 /// Sequence number for ordering execution
366 pub sequence_num: i32,
367 /// Database containing the object
368 pub database: String,
369 /// Schema containing the object
370 pub schema: String,
371 /// Object name
372 pub object: String,
373 /// Hash of the object definition
374 pub object_hash: String,
375 /// SQL statement to execute
376 pub statement_sql: String,
377 /// Kind of statement (e.g., "sink")
378 pub statement_kind: String,
379 /// When this statement was executed (None if not yet executed)
380 pub executed_at: Option<DateTime<Utc>>,
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use mz_sql_parser::ast::Statement;
387 use mz_sql_parser::parser::parse_statements;
388
389 fn create_cluster(sql: &str) -> CreateClusterStatement<Raw> {
390 match parse_statements(sql).unwrap().pop().unwrap().ast {
391 Statement::CreateCluster(stmt) => stmt,
392 other => panic!("expected CREATE CLUSTER, got {:?}", other),
393 }
394 }
395
396 #[mz_ore::test]
397 fn test_deployment_kind_display() {
398 assert_eq!(DeploymentKind::Tables.to_string(), "tables");
399 assert_eq!(DeploymentKind::Objects.to_string(), "objects");
400 }
401
402 #[mz_ore::test]
403 fn test_deployment_kind_from_str_valid() {
404 assert_eq!(
405 "tables".parse::<DeploymentKind>().unwrap(),
406 DeploymentKind::Tables
407 );
408 assert_eq!(
409 "objects".parse::<DeploymentKind>().unwrap(),
410 DeploymentKind::Objects
411 );
412 }
413
414 #[mz_ore::test]
415 fn test_deployment_kind_from_str_invalid() {
416 let result = "invalid".parse::<DeploymentKind>();
417 assert!(result.is_err());
418 assert_eq!(result.unwrap_err(), "Invalid deployment kind: invalid");
419 }
420
421 #[mz_ore::test]
422 fn test_deployment_kind_roundtrip() {
423 // Verify that Display and FromStr are consistent
424 for kind in [
425 DeploymentKind::Tables,
426 DeploymentKind::Objects,
427 DeploymentKind::Sinks,
428 DeploymentKind::Replacement,
429 ] {
430 let s = kind.to_string();
431 let parsed: DeploymentKind = s.parse().unwrap();
432 assert_eq!(kind, parsed);
433 }
434 }
435
436 #[mz_ore::test]
437 fn test_cluster_equality() {
438 let cluster1 = Cluster {
439 id: "u1".to_string(),
440 name: "test".to_string(),
441 managed: true,
442 size: Some("25cc".to_string()),
443 replication_factor: Some(1),
444 };
445
446 let cluster2 = Cluster {
447 id: "u1".to_string(),
448 name: "test".to_string(),
449 managed: true,
450 size: Some("25cc".to_string()),
451 replication_factor: Some(1),
452 };
453
454 let cluster3 = Cluster {
455 id: "u2".to_string(), // Different ID
456 name: "test".to_string(),
457 managed: true,
458 size: Some("25cc".to_string()),
459 replication_factor: Some(1),
460 };
461
462 assert_eq!(cluster1, cluster2);
463 assert_ne!(cluster1, cluster3);
464 }
465
466 #[mz_ore::test]
467 fn test_cluster_replica_equality() {
468 let r1 = ClusterReplica {
469 name: "r1".to_string(),
470 size: "25cc".to_string(),
471 availability_zone: Some("use1-az1".to_string()),
472 };
473
474 let r2 = ClusterReplica {
475 name: "r1".to_string(),
476 size: "25cc".to_string(),
477 availability_zone: Some("use1-az1".to_string()),
478 };
479
480 let r3 = ClusterReplica {
481 name: "r2".to_string(),
482 size: "25cc".to_string(),
483 availability_zone: None,
484 };
485
486 assert_eq!(r1, r2);
487 assert_ne!(r1, r3);
488 }
489
490 #[mz_ore::test]
491 fn test_cluster_grant_equality() {
492 let g1 = ObjectGrant {
493 grantee: "reader".to_string(),
494 privilege_type: "USAGE".to_string(),
495 };
496
497 let g2 = ObjectGrant {
498 grantee: "reader".to_string(),
499 privilege_type: "USAGE".to_string(),
500 };
501
502 let g3 = ObjectGrant {
503 grantee: "writer".to_string(),
504 privilege_type: "CREATE".to_string(),
505 };
506
507 assert_eq!(g1, g2);
508 assert_ne!(g1, g3);
509 }
510
511 #[mz_ore::test]
512 fn test_cluster_config_managed() {
513 let config = ClusterConfig::Managed {
514 create_stmt: create_cluster("CREATE CLUSTER c (SIZE = '25cc')"),
515 grants: vec![ObjectGrant {
516 grantee: "reader".to_string(),
517 privilege_type: "USAGE".to_string(),
518 }],
519 };
520
521 assert_eq!(config.grants().len(), 1);
522 assert_eq!(config.grants()[0].grantee, "reader");
523 }
524
525 #[mz_ore::test]
526 fn test_cluster_config_unmanaged() {
527 let config = ClusterConfig::Unmanaged {
528 replicas: vec![
529 ClusterReplica {
530 name: "r1".to_string(),
531 size: "25cc".to_string(),
532 availability_zone: None,
533 },
534 ClusterReplica {
535 name: "r2".to_string(),
536 size: "50cc".to_string(),
537 availability_zone: Some("use1-az1".to_string()),
538 },
539 ],
540 grants: vec![],
541 };
542
543 if let ClusterConfig::Unmanaged { replicas, grants } = &config {
544 assert_eq!(replicas.len(), 2);
545 assert_eq!(replicas[0].name, "r1");
546 assert_eq!(replicas[1].availability_zone, Some("use1-az1".to_string()));
547 assert!(grants.is_empty());
548 } else {
549 panic!("Expected Unmanaged config");
550 }
551 }
552
553 #[mz_ore::test]
554 fn test_cluster_config_unmanaged_empty_replicas() {
555 // Unmanaged clusters with 0 replicas are valid
556 let config = ClusterConfig::Unmanaged {
557 replicas: vec![],
558 grants: vec![ObjectGrant {
559 grantee: "admin".to_string(),
560 privilege_type: "CREATE".to_string(),
561 }],
562 };
563
564 if let ClusterConfig::Unmanaged { replicas, grants } = &config {
565 assert!(replicas.is_empty());
566 assert_eq!(grants.len(), 1);
567 } else {
568 panic!("Expected Unmanaged config");
569 }
570 }
571}