1use serde::{Deserialize, Serialize};
2use std::env;
3
4use crate::ast::ProtoSchema;
5use crate::backend::BackendKind;
6
7use super::manifest::{CatalogManifest, ManifestStore};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct DsnGenerationConfig {
11 pub sql_env_key: String,
12 pub nosql_env_key: String,
13 pub vector_env_key: String,
14 pub graph_env_key: String,
15 pub timeseries_env_key: String,
16 pub column_env_key: String,
17 pub cache_env_key: String,
18 pub object_env_key: String,
19 pub fallback_env_key: String,
20}
21
22impl Default for DsnGenerationConfig {
23 fn default() -> Self {
24 Self {
25 sql_env_key: "UDB_SQL_DSN".to_string(),
26 nosql_env_key: "UDB_NOSQL_DSN".to_string(),
27 vector_env_key: "UDB_VECTOR_DSN".to_string(),
28 graph_env_key: "UDB_GRAPH_DSN".to_string(),
29 timeseries_env_key: "UDB_TIMESERIES_DSN".to_string(),
30 column_env_key: "UDB_COLUMN_DSN".to_string(),
31 cache_env_key: "UDB_CACHE_DSN".to_string(),
32 object_env_key: "UDB_OBJECT_DSN".to_string(),
33 fallback_env_key: "UDB_GENERIC_DSN".to_string(),
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
39pub struct UnifiedDsnCatalog {
40 pub checksum_sha256: String,
41 pub entries: Vec<UnifiedDsn>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
45pub struct UnifiedDsn {
46 pub id: String,
47 pub message_name: String,
48 pub store_kind: String,
49 pub backend: String,
50 pub env_key: String,
51 pub dsn: String,
52 pub resource_uri: String,
53 pub owner_schema: String,
54 pub owner_table: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
58pub struct ParsedUnifiedDsn {
59 pub scheme: String,
60 pub tier: String,
61 pub backend: String,
62 pub env_key: String,
63 pub resource_path: String,
64 pub resource_parts: Vec<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
68pub struct ResolvedUnifiedDsn {
69 pub id: String,
70 pub dsn: String,
71 pub env_key: String,
72 pub base_dsn: String,
73 pub redacted_base_dsn: String,
74 pub resource_path: String,
75 pub valid: bool,
76 pub error: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
80pub struct DsnValidationReport {
81 pub passed: bool,
82 pub errors: Vec<String>,
83}
84
85impl UnifiedDsn {
86 pub fn parsed(&self) -> Result<ParsedUnifiedDsn, String> {
87 parse_unified_dsn(&self.dsn)
88 }
89
90 pub fn validate(&self) -> DsnValidationReport {
91 validate_unified_dsn(self)
92 }
93
94 pub fn resolved_from_env(&self) -> ResolvedUnifiedDsn {
95 resolve_unified_dsn(self)
96 }
97}
98
99pub fn generate_unified_dsn_catalog(
100 schemas: &[ProtoSchema],
101 config: &DsnGenerationConfig,
102) -> Result<UnifiedDsnCatalog, serde_json::Error> {
103 let manifest = CatalogManifest::from_schemas(schemas)?;
104 let mut entries = Vec::new();
105
106 for table in &manifest.tables {
107 let env_key = config.sql_env_key.clone();
108 let backend = "postgres".to_string();
109 entries.push(UnifiedDsn {
110 id: format!("sql:{}:{}", table.schema, table.table),
111 message_name: table.message_name.clone(),
112 store_kind: "sql".to_string(),
113 backend: backend.clone(),
114 env_key: env_key.clone(),
115 dsn: format!(
116 "{}://env:{}/{}/{}",
117 BackendKind::Postgres.dsn_scheme(),
118 env_key,
119 table.schema,
120 table.table
121 ),
122 resource_uri: format!("sql://{}/{}", table.schema, table.table),
123 owner_schema: table.schema.clone(),
124 owner_table: table.table.clone(),
125 });
126 }
127
128 for store in &manifest.stores {
129 entries.push(dsn_from_store(store, config));
130 }
131
132 entries.sort_by(|a, b| a.id.cmp(&b.id));
133 Ok(UnifiedDsnCatalog {
134 checksum_sha256: manifest.checksum_sha256,
135 entries,
136 })
137}
138
139pub fn parse_unified_dsn(dsn: &str) -> Result<ParsedUnifiedDsn, String> {
140 let (scheme, rest) = dsn
141 .split_once("://")
142 .ok_or_else(|| "DSN must contain ://".to_string())?;
143 let scheme_parts = scheme.split('+').collect::<Vec<_>>();
144 if scheme_parts.len() != 3 || scheme_parts[0] != "udb" {
145 return Err("DSN scheme must be udb+<tier>+<backend>".to_string());
146 }
147 let tier = scheme_parts[1].trim();
148 let backend = scheme_parts[2].trim();
149 if tier.is_empty() || backend.is_empty() {
150 return Err("DSN tier and backend must be non-empty".to_string());
151 }
152 if BackendKind::from_store_kind(tier, backend).is_none() && backend != "generic" {
153 return Err(format!(
154 "unsupported backend '{}' for tier '{}'",
155 backend, tier
156 ));
157 }
158
159 let (authority, resource_path) = rest
160 .split_once('/')
161 .ok_or_else(|| "DSN must include env authority and resource path".to_string())?;
162 let env_key = authority
163 .strip_prefix("env:")
164 .ok_or_else(|| "DSN authority must be env:<ENV_KEY>".to_string())?
165 .trim();
166 if env_key.is_empty() {
167 return Err("DSN env key must be non-empty".to_string());
168 }
169 let resource_path = resource_path.trim_matches('/');
170 if resource_path.is_empty() {
171 return Err("DSN resource path must be non-empty".to_string());
172 }
173
174 Ok(ParsedUnifiedDsn {
175 scheme: scheme.to_string(),
176 tier: tier.to_string(),
177 backend: backend.to_string(),
178 env_key: env_key.to_string(),
179 resource_path: resource_path.to_string(),
180 resource_parts: resource_path
181 .split('/')
182 .filter(|part| !part.trim().is_empty())
183 .map(str::to_string)
184 .collect(),
185 })
186}
187
188pub fn validate_unified_dsn(entry: &UnifiedDsn) -> DsnValidationReport {
189 let mut errors = Vec::new();
190 match parse_unified_dsn(&entry.dsn) {
191 Ok(parsed) => {
192 if parsed.backend != entry.backend {
193 errors.push(format!(
194 "backend mismatch: entry={} dsn={}",
195 entry.backend, parsed.backend
196 ));
197 }
198 if parsed.env_key != entry.env_key {
199 errors.push(format!(
200 "env key mismatch: entry={} dsn={}",
201 entry.env_key, parsed.env_key
202 ));
203 }
204 if !entry.owner_schema.trim().is_empty()
205 && !parsed
206 .resource_parts
207 .iter()
208 .any(|part| part == &entry.owner_schema)
209 {
210 errors.push(format!(
211 "resource path '{}' does not include owner schema '{}'",
212 parsed.resource_path, entry.owner_schema
213 ));
214 }
215 }
216 Err(err) => errors.push(err),
217 }
218 DsnValidationReport {
219 passed: errors.is_empty(),
220 errors,
221 }
222}
223
224pub fn resolve_unified_dsn(entry: &UnifiedDsn) -> ResolvedUnifiedDsn {
225 match parse_unified_dsn(&entry.dsn) {
226 Ok(parsed) => match env::var(&parsed.env_key) {
227 Ok(base_dsn) => {
228 if let Err(err) = validate_base_dsn(&base_dsn) {
229 return ResolvedUnifiedDsn {
230 id: entry.id.clone(),
231 dsn: entry.dsn.clone(),
232 env_key: parsed.env_key,
233 redacted_base_dsn: redact_dsn(&base_dsn),
234 base_dsn,
235 resource_path: parsed.resource_path,
236 valid: false,
237 error: err,
238 };
239 }
240 ResolvedUnifiedDsn {
241 id: entry.id.clone(),
242 dsn: entry.dsn.clone(),
243 env_key: parsed.env_key,
244 redacted_base_dsn: redact_dsn(&base_dsn),
245 base_dsn,
246 resource_path: parsed.resource_path,
247 valid: true,
248 error: String::new(),
249 }
250 }
251 Err(err) => ResolvedUnifiedDsn {
252 id: entry.id.clone(),
253 dsn: entry.dsn.clone(),
254 env_key: parsed.env_key,
255 resource_path: parsed.resource_path,
256 valid: false,
257 error: format!("{}: {}", entry.env_key, err),
258 ..ResolvedUnifiedDsn::default()
259 },
260 },
261 Err(err) => ResolvedUnifiedDsn {
262 id: entry.id.clone(),
263 dsn: entry.dsn.clone(),
264 valid: false,
265 error: err,
266 ..ResolvedUnifiedDsn::default()
267 },
268 }
269}
270
271fn validate_base_dsn(dsn: &str) -> Result<(), String> {
272 let (scheme, rest) = dsn
273 .split_once("://")
274 .ok_or_else(|| "base DSN is missing scheme://".to_string())?;
275 if scheme.trim().is_empty() {
276 return Err("base DSN scheme is empty".to_string());
277 }
278 let authority = rest.split('/').next().unwrap_or_default();
279 let host = authority.rsplit('@').next().unwrap_or_default();
280 if host.trim().is_empty() {
281 return Err("base DSN host is empty".to_string());
282 }
283 Ok(())
284}
285
286pub fn resolve_unified_dsn_catalog(catalog: &UnifiedDsnCatalog) -> Vec<ResolvedUnifiedDsn> {
287 catalog.entries.iter().map(resolve_unified_dsn).collect()
288}
289
290pub fn redact_dsn(dsn: &str) -> String {
291 let Some((scheme, rest)) = dsn.split_once("://") else {
292 return dsn.to_string();
293 };
294 let Some((auth, host_and_path)) = rest.split_once('@') else {
299 return dsn.to_string();
300 };
301 if let Some((user, _password)) = auth.split_once(':') {
302 format!("{scheme}://{user}:***@{host_and_path}")
303 } else {
304 format!("{scheme}://***@{host_and_path}")
305 }
306}
307
308fn dsn_from_store(store: &ManifestStore, config: &DsnGenerationConfig) -> UnifiedDsn {
309 let env_key = if !store.dsn_env_key.trim().is_empty() {
310 store.dsn_env_key.clone()
311 } else {
312 env_key_for_kind(&store.store_kind, config).to_string()
313 };
314 let backend_kind = BackendKind::from_store_kind(&store.store_kind, &store.backend);
315 let backend = backend_kind
316 .as_ref()
317 .map(|kind| kind.as_str().to_string())
318 .unwrap_or_else(|| {
319 if store.backend.trim().is_empty() {
320 "generic".to_string()
321 } else {
322 store.backend.clone()
323 }
324 });
325 let scheme = backend_kind
326 .as_ref()
327 .map(|kind| kind.dsn_scheme())
328 .unwrap_or_else(|| format!("udb+{}+{}", store.store_kind, backend));
329 let dsn = if !store.dsn.trim().is_empty() {
330 store.dsn.clone()
331 } else {
332 format!("{}://env:{}/{}", scheme, env_key, store_path(store))
333 };
334
335 UnifiedDsn {
336 id: format!(
337 "{}:{}:{}:{}",
338 store.store_kind, backend, store.owner_schema, store.resource_name
339 ),
340 message_name: store.logical_name.clone(),
341 store_kind: store.store_kind.clone(),
342 backend: backend.clone(),
343 env_key,
344 dsn,
345 resource_uri: format!("{}://{}/{}", store.store_kind, backend, store_path(store)),
346 owner_schema: store.owner_schema.clone(),
347 owner_table: store.owner_table.clone(),
348 }
349}
350
351fn env_key_for_kind<'a>(kind: &str, config: &'a DsnGenerationConfig) -> &'a str {
352 match kind {
353 "sql" => &config.sql_env_key,
354 "nosql" | "document" => &config.nosql_env_key,
355 "vector" => &config.vector_env_key,
356 "graph" => &config.graph_env_key,
357 "timeseries" | "time-series" => &config.timeseries_env_key,
358 "column" | "columnar" | "wide-column" => &config.column_env_key,
359 "cache" => &config.cache_env_key,
360 "object" | "blob" | "storage" => &config.object_env_key,
361 _ => &config.fallback_env_key,
362 }
363}
364
365fn store_path(store: &ManifestStore) -> String {
366 let mut parts = Vec::new();
367 if !store.database_name.trim().is_empty() {
368 parts.push(store.database_name.as_str());
369 }
370 if !store.namespace.trim().is_empty() {
371 parts.push(store.namespace.as_str());
372 }
373 if !store.resource_name.trim().is_empty() {
374 parts.push(store.resource_name.as_str());
375 }
376 if parts.is_empty() {
377 format!("{}/{}", store.owner_schema, store.owner_table)
378 } else {
379 parts.join("/")
380 }
381}