1use crate::{
2 DatabaseBackend, DbLifecycleError, canonicalize, sha256_hex, validate_owner, validate_stable_id,
3};
4use serde::{Deserialize, Serialize};
5use std::{
6 collections::{BTreeMap, BTreeSet},
7 fs,
8 path::{Component, Path, PathBuf},
9};
10
11pub const SEED_SET_MANIFEST: &str = ".minco-seeds.toml";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SeedClass {
16 Reference,
17 Demo,
18 Test,
19 Bootstrap,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SeedEnvironment {
25 Local,
26 Development,
27 Test,
28 Staging,
29 Production,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum SeedIdempotency {
35 InsertOnce,
36 Upsert,
37 Reconcile,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum SeedMutableState {
43 None,
44 OwnedRows,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum SeedRisk {
50 NonDestructive,
51 ReplacesOwnedRows,
52 Destructive,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum SeedTransaction {
58 Required,
59 Autocommit,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum SeedPreservation {
65 PreserveAllExisting,
66 PreserveUnownedRows,
67 ReplaceOwnedRows,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct SeedCatalog {
72 pub schema_version: u32,
73 pub digest: String,
74 pub sets: Vec<SeedSet>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SeedSet {
79 pub id: String,
80 pub owner: String,
81 pub backend: DatabaseBackend,
82 pub root: PathBuf,
83 pub digest: String,
84 pub seeds: Vec<Seed>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct SeedPlan {
89 pub schema_version: u32,
90 pub catalog_digest: String,
91 pub profile: SeedClass,
92 pub environment: SeedEnvironment,
93 pub selected_set: Option<String>,
94 pub digest: String,
95 pub seeds: Vec<Seed>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct Seed {
100 pub id: String,
101 pub version: u32,
102 pub set_id: String,
103 pub root: PathBuf,
104 pub owner: String,
105 pub backend: DatabaseBackend,
106 pub class: SeedClass,
107 pub source: PathBuf,
108 pub source_sha256: String,
109 pub verify: PathBuf,
110 pub verify_sha256: String,
111 pub depends_on: Vec<String>,
112 pub environments: Vec<SeedEnvironment>,
113 pub idempotency: SeedIdempotency,
114 pub mutable_state: SeedMutableState,
115 pub risk: SeedRisk,
116 pub transaction: SeedTransaction,
117 pub preservation: SeedPreservation,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ResolvedSeedSource {
122 pub apply_sql: String,
123 pub verify_sql: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct SeedVerification {
128 pub seed_id: String,
129 pub verified: bool,
130}
131
132#[derive(Debug, Deserialize)]
133#[serde(deny_unknown_fields)]
134struct SeedSetManifest {
135 schema: u32,
136 id: String,
137 owner: String,
138 backend: DatabaseBackend,
139 seed: Vec<SeedManifest>,
140}
141
142#[derive(Debug, Deserialize)]
143#[serde(deny_unknown_fields)]
144struct SeedManifest {
145 id: String,
146 version: u32,
147 class: SeedClass,
148 source: PathBuf,
149 verify: PathBuf,
150 #[serde(default)]
151 depends_on: Vec<String>,
152 environments: Vec<SeedEnvironment>,
153 idempotency: SeedIdempotency,
154 mutable_state: SeedMutableState,
155 risk: SeedRisk,
156 transaction: SeedTransaction,
157 preservation: SeedPreservation,
158}
159
160pub fn load_seed_catalog(
161 project_root: &Path,
162 seed_roots: &[PathBuf],
163) -> Result<SeedCatalog, DbLifecycleError> {
164 let project_root = canonicalize(project_root)?;
165 let mut configured_roots = BTreeSet::new();
166 let mut sets = Vec::new();
167 for configured_root in seed_roots {
168 if configured_root.is_absolute() {
169 return Err(DbLifecycleError::Invalid(format!(
170 "seed root {} must be relative to the project",
171 configured_root.display()
172 )));
173 }
174 let root = canonicalize(&project_root.join(configured_root))?;
175 if !root.starts_with(&project_root) {
176 return Err(DbLifecycleError::Invalid(format!(
177 "seed root {} escapes the project",
178 configured_root.display()
179 )));
180 }
181 let relative_root = root
182 .strip_prefix(&project_root)
183 .map_err(|_| {
184 DbLifecycleError::Invalid(format!(
185 "seed root {} escapes the project",
186 configured_root.display()
187 ))
188 })?
189 .to_path_buf();
190 if !configured_roots.insert(relative_root.clone()) {
191 return Err(DbLifecycleError::Invalid(format!(
192 "seed root {} is configured more than once",
193 relative_root.display()
194 )));
195 }
196 sets.push(load_seed_set(&project_root, &root, relative_root)?);
197 }
198 sets.sort_by(|left, right| left.id.cmp(&right.id));
199 let mut set_ids = BTreeSet::new();
200 let mut seed_ids = BTreeSet::new();
201 for set in &sets {
202 if !set_ids.insert(set.id.as_str()) {
203 return Err(DbLifecycleError::Invalid(format!(
204 "seed catalog repeats seed set ID {}",
205 set.id
206 )));
207 }
208 for seed in &set.seeds {
209 if !seed_ids.insert(seed.id.as_str()) {
210 return Err(DbLifecycleError::Invalid(format!(
211 "seed catalog repeats seed ID {}",
212 seed.id
213 )));
214 }
215 }
216 }
217 validate_seed_dependencies(&sets)?;
218 let digest = sha256_hex(&serde_json::to_vec(&sets)?);
219 Ok(SeedCatalog {
220 schema_version: 1,
221 digest,
222 sets,
223 })
224}
225
226fn load_seed_set(
227 project_root: &Path,
228 root: &Path,
229 relative_root: PathBuf,
230) -> Result<SeedSet, DbLifecycleError> {
231 let manifest_path = canonicalize(&root.join(SEED_SET_MANIFEST))?;
232 if !manifest_path.starts_with(root) {
233 return Err(DbLifecycleError::Invalid(format!(
234 "seed metadata for root {} escapes its configured root",
235 relative_root.display()
236 )));
237 }
238 let manifest: SeedSetManifest =
239 toml::from_str(&fs::read_to_string(&manifest_path).map_err(|source| {
240 DbLifecycleError::Io {
241 path: manifest_path.clone(),
242 source,
243 }
244 })?)
245 .map_err(|source| DbLifecycleError::Toml {
246 path: manifest_path,
247 source,
248 })?;
249 if manifest.schema != 1 {
250 return Err(DbLifecycleError::Invalid(format!(
251 "seed set {} uses unsupported schema {}",
252 manifest.id, manifest.schema
253 )));
254 }
255 validate_stable_id(&manifest.id, "seed set ID")?;
256 validate_owner(&manifest.owner)?;
257 if manifest.seed.is_empty() {
258 return Err(DbLifecycleError::Invalid(format!(
259 "seed set {} contains no seeds",
260 manifest.id
261 )));
262 }
263 let mut seeds = Vec::with_capacity(manifest.seed.len());
264 let mut local_ids = BTreeSet::new();
265 for entry in manifest.seed {
266 validate_stable_id(&entry.id, "seed ID")?;
267 if !local_ids.insert(entry.id.clone()) {
268 return Err(DbLifecycleError::Invalid(format!(
269 "seed set {} repeats seed ID {}",
270 manifest.id, entry.id
271 )));
272 }
273 if entry.version == 0 {
274 return Err(DbLifecycleError::Invalid(format!(
275 "seed {} has a zero version",
276 entry.id
277 )));
278 }
279 if entry.environments.is_empty() {
280 return Err(DbLifecycleError::Invalid(format!(
281 "seed {} has an empty environment allowlist",
282 entry.id
283 )));
284 }
285 let source = bounded_seed_file(project_root, root, &entry.source, "source")?;
286 let verify = bounded_seed_file(project_root, root, &entry.verify, "verification")?;
287 let source_bytes =
288 fs::read(project_root.join(&source)).map_err(|source_error| DbLifecycleError::Io {
289 path: source.clone(),
290 source: source_error,
291 })?;
292 let verify_bytes =
293 fs::read(project_root.join(&verify)).map_err(|source_error| DbLifecycleError::Io {
294 path: verify.clone(),
295 source: source_error,
296 })?;
297 let mut dependencies = BTreeSet::new();
298 for dependency in entry.depends_on {
299 validate_stable_id(&dependency, "seed dependency")?;
300 if dependency == entry.id {
301 return Err(DbLifecycleError::Invalid(format!(
302 "seed {} cannot depend on itself",
303 entry.id
304 )));
305 }
306 if !dependencies.insert(dependency.clone()) {
307 return Err(DbLifecycleError::Invalid(format!(
308 "seed {} repeats dependency {}",
309 entry.id, dependency
310 )));
311 }
312 }
313 let environment_count = entry.environments.len();
314 let environments = entry.environments.into_iter().collect::<BTreeSet<_>>();
315 if environments.len() != environment_count {
316 return Err(DbLifecycleError::Invalid(format!(
317 "seed {} repeats an environment allowlist entry",
318 entry.id
319 )));
320 }
321 seeds.push(Seed {
322 id: entry.id,
323 version: entry.version,
324 set_id: manifest.id.clone(),
325 root: relative_root.clone(),
326 owner: manifest.owner.clone(),
327 backend: manifest.backend,
328 class: entry.class,
329 source,
330 source_sha256: sha256_hex(&source_bytes),
331 verify,
332 verify_sha256: sha256_hex(&verify_bytes),
333 depends_on: dependencies.into_iter().collect(),
334 environments: environments.into_iter().collect(),
335 idempotency: entry.idempotency,
336 mutable_state: entry.mutable_state,
337 risk: entry.risk,
338 transaction: entry.transaction,
339 preservation: entry.preservation,
340 });
341 }
342 seeds.sort_by(|left, right| left.id.cmp(&right.id));
343 let mut set = SeedSet {
344 id: manifest.id,
345 owner: manifest.owner,
346 backend: manifest.backend,
347 root: relative_root,
348 digest: String::new(),
349 seeds,
350 };
351 set.digest = sha256_hex(&serde_json::to_vec(&set)?);
352 Ok(set)
353}
354
355pub fn resolve_seed_source(
356 project_root: &Path,
357 seed: &Seed,
358) -> Result<ResolvedSeedSource, DbLifecycleError> {
359 let project_root = canonicalize(project_root)?;
360 let root = canonicalize(&project_root.join(&seed.root))?;
361 if !root.starts_with(&project_root) {
362 return Err(DbLifecycleError::Invalid(format!(
363 "seed {} source root escapes the project",
364 seed.id
365 )));
366 }
367 let apply_sql = read_planned_seed_file(
368 &project_root,
369 &root,
370 &seed.source,
371 &seed.source_sha256,
372 &seed.id,
373 "source",
374 )?;
375 let verify_sql = read_planned_seed_file(
376 &project_root,
377 &root,
378 &seed.verify,
379 &seed.verify_sha256,
380 &seed.id,
381 "verification",
382 )?;
383 Ok(ResolvedSeedSource {
384 apply_sql,
385 verify_sql,
386 })
387}
388
389pub fn build_seed_plan(
390 catalog: &SeedCatalog,
391 profile: SeedClass,
392 environment: SeedEnvironment,
393 selected_set: Option<&str>,
394) -> Result<SeedPlan, DbLifecycleError> {
395 if environment == SeedEnvironment::Production
396 && matches!(profile, SeedClass::Demo | SeedClass::Test)
397 {
398 let profile_name = match profile {
399 SeedClass::Demo => "demo",
400 SeedClass::Test => "test",
401 SeedClass::Reference | SeedClass::Bootstrap => unreachable!(),
402 };
403 return Err(DbLifecycleError::Invalid(format!(
404 "{profile_name} seeds are forbidden in production"
405 )));
406 }
407 if let Some(selected_set) = selected_set
408 && !catalog.sets.iter().any(|set| set.id == selected_set)
409 {
410 return Err(DbLifecycleError::Invalid(format!(
411 "unknown seed set {selected_set}"
412 )));
413 }
414 let by_id = catalog
415 .sets
416 .iter()
417 .flat_map(|set| set.seeds.iter())
418 .map(|seed| (seed.id.as_str(), seed))
419 .collect::<BTreeMap<_, _>>();
420 let roots = by_id
421 .values()
422 .filter(|seed| seed.class == profile)
423 .filter(|seed| selected_set.is_none_or(|set| seed.set_id == set))
424 .map(|seed| seed.id.as_str())
425 .collect::<Vec<_>>();
426 if roots.is_empty() {
427 return Err(DbLifecycleError::Invalid(format!(
428 "seed profile {profile:?} selects no seeds"
429 )));
430 }
431 let mut visiting = BTreeSet::new();
432 let mut visited = BTreeSet::new();
433 let mut ordered = Vec::new();
434 for id in roots {
435 collect_seed(
436 id,
437 &by_id,
438 environment,
439 &mut visiting,
440 &mut visited,
441 &mut ordered,
442 )?;
443 }
444 let seeds = ordered.into_iter().cloned().collect::<Vec<_>>();
445 if seeds
446 .iter()
447 .any(|seed| seed.transaction != seeds[0].transaction)
448 {
449 return Err(DbLifecycleError::Invalid(
450 "an executable seed plan cannot mix transaction behaviors".into(),
451 ));
452 }
453 let selected_set = selected_set.map(str::to_owned);
454 let digest_input = serde_json::to_vec(&(
455 1_u32,
456 catalog.digest.as_str(),
457 profile,
458 environment,
459 selected_set.as_deref(),
460 &seeds,
461 ))?;
462 let plan = SeedPlan {
463 schema_version: 1,
464 catalog_digest: catalog.digest.clone(),
465 profile,
466 environment,
467 selected_set,
468 digest: sha256_hex(&digest_input),
469 seeds,
470 };
471 validate_seed_plan(&plan)?;
472 Ok(plan)
473}
474
475pub fn validate_seed_plan(plan: &SeedPlan) -> Result<(), DbLifecycleError> {
476 if plan.schema_version != 1 {
477 return Err(DbLifecycleError::Invalid(format!(
478 "seed plan uses unsupported schema {}",
479 plan.schema_version
480 )));
481 }
482 if plan.seeds.is_empty() {
483 return Err(DbLifecycleError::Invalid(
484 "seed plan contains no seeds".into(),
485 ));
486 }
487 let mut ordered_ids = BTreeSet::new();
488 for seed in &plan.seeds {
489 if ordered_ids.contains(seed.id.as_str()) {
490 return Err(DbLifecycleError::Invalid(format!(
491 "seed plan repeats seed ID {}",
492 seed.id
493 )));
494 }
495 if plan.environment == SeedEnvironment::Production
496 && matches!(seed.class, SeedClass::Demo | SeedClass::Test)
497 {
498 return Err(DbLifecycleError::Invalid(format!(
499 "seed {} is forbidden in production because it is classified as {:?}",
500 seed.id, seed.class
501 )));
502 }
503 if !seed.environments.contains(&plan.environment) {
504 return Err(DbLifecycleError::Invalid(format!(
505 "seed {} is not allowed in environment {:?}",
506 seed.id, plan.environment
507 )));
508 }
509 for dependency in &seed.depends_on {
510 if !ordered_ids.contains(dependency.as_str()) {
511 return Err(DbLifecycleError::Invalid(format!(
512 "seed {} dependency {} is absent or ordered after it",
513 seed.id, dependency
514 )));
515 }
516 }
517 ordered_ids.insert(seed.id.as_str());
518 }
519 if plan
520 .seeds
521 .iter()
522 .any(|seed| seed.transaction != plan.seeds[0].transaction)
523 {
524 return Err(DbLifecycleError::Invalid(
525 "an executable seed plan cannot mix transaction behaviors".into(),
526 ));
527 }
528 let digest_input = serde_json::to_vec(&(
529 1_u32,
530 plan.catalog_digest.as_str(),
531 plan.profile,
532 plan.environment,
533 plan.selected_set.as_deref(),
534 &plan.seeds,
535 ))?;
536 if plan.digest != sha256_hex(&digest_input) {
537 return Err(DbLifecycleError::Invalid(
538 "seed plan digest does not match its contents".into(),
539 ));
540 }
541 Ok(())
542}
543
544fn collect_seed<'a>(
545 id: &'a str,
546 seeds: &BTreeMap<&'a str, &'a Seed>,
547 environment: SeedEnvironment,
548 visiting: &mut BTreeSet<&'a str>,
549 visited: &mut BTreeSet<&'a str>,
550 ordered: &mut Vec<&'a Seed>,
551) -> Result<(), DbLifecycleError> {
552 if visited.contains(id) {
553 return Ok(());
554 }
555 if !visiting.insert(id) {
556 return Err(DbLifecycleError::Invalid(format!(
557 "seed dependency cycle contains {id}"
558 )));
559 }
560 let seed = seeds
561 .get(id)
562 .ok_or_else(|| DbLifecycleError::Invalid(format!("unknown seed {id}")))?;
563 if environment == SeedEnvironment::Production
564 && matches!(seed.class, SeedClass::Demo | SeedClass::Test)
565 {
566 return Err(DbLifecycleError::Invalid(format!(
567 "seed {} is forbidden in production because it is classified as {:?}",
568 seed.id, seed.class
569 )));
570 }
571 if !seed.environments.contains(&environment) {
572 return Err(DbLifecycleError::Invalid(format!(
573 "seed {} is not allowed in environment {environment:?}",
574 seed.id
575 )));
576 }
577 for dependency in &seed.depends_on {
578 let dependency_seed = seeds.get(dependency.as_str()).ok_or_else(|| {
579 DbLifecycleError::Invalid(format!(
580 "seed {} depends on unknown seed {}",
581 seed.id, dependency
582 ))
583 })?;
584 if dependency_seed.backend != seed.backend {
585 return Err(DbLifecycleError::Invalid(format!(
586 "seed {} cannot depend on {} because their backends differ",
587 seed.id, dependency
588 )));
589 }
590 collect_seed(dependency, seeds, environment, visiting, visited, ordered)?;
591 }
592 visiting.remove(id);
593 visited.insert(id);
594 ordered.push(seed);
595 Ok(())
596}
597
598fn validate_seed_dependencies(sets: &[SeedSet]) -> Result<(), DbLifecycleError> {
599 let seeds = sets
600 .iter()
601 .flat_map(|set| set.seeds.iter())
602 .map(|seed| (seed.id.as_str(), seed))
603 .collect::<BTreeMap<_, _>>();
604 for seed in seeds.values() {
605 for dependency in &seed.depends_on {
606 let dependency_seed = seeds.get(dependency.as_str()).ok_or_else(|| {
607 DbLifecycleError::Invalid(format!(
608 "seed {} depends on unknown seed {}",
609 seed.id, dependency
610 ))
611 })?;
612 if dependency_seed.backend != seed.backend {
613 return Err(DbLifecycleError::Invalid(format!(
614 "seed {} cannot depend on {} because their backends differ",
615 seed.id, dependency
616 )));
617 }
618 }
619 }
620 let mut visiting = BTreeSet::new();
621 let mut visited = BTreeSet::new();
622 for id in seeds.keys().copied() {
623 visit_seed_dependency(id, &seeds, &mut visiting, &mut visited)?;
624 }
625 Ok(())
626}
627
628fn visit_seed_dependency<'a>(
629 id: &'a str,
630 seeds: &BTreeMap<&'a str, &'a Seed>,
631 visiting: &mut BTreeSet<&'a str>,
632 visited: &mut BTreeSet<&'a str>,
633) -> Result<(), DbLifecycleError> {
634 if visited.contains(id) {
635 return Ok(());
636 }
637 if !visiting.insert(id) {
638 return Err(DbLifecycleError::Invalid(format!(
639 "seed dependency cycle contains {id}"
640 )));
641 }
642 let seed = seeds
643 .get(id)
644 .ok_or_else(|| DbLifecycleError::Invalid(format!("unknown seed {id}")))?;
645 for dependency in &seed.depends_on {
646 visit_seed_dependency(dependency, seeds, visiting, visited)?;
647 }
648 visiting.remove(id);
649 visited.insert(id);
650 Ok(())
651}
652
653fn bounded_seed_file(
654 project_root: &Path,
655 root: &Path,
656 relative: &Path,
657 kind: &str,
658) -> Result<PathBuf, DbLifecycleError> {
659 if relative.as_os_str().is_empty()
660 || relative.is_absolute()
661 || relative.components().any(|component| {
662 matches!(
663 component,
664 Component::ParentDir | Component::RootDir | Component::Prefix(_)
665 )
666 })
667 || relative.extension().and_then(|value| value.to_str()) != Some("sql")
668 {
669 return Err(DbLifecycleError::Invalid(format!(
670 "seed {kind} path {} must be a relative SQL file",
671 relative.display()
672 )));
673 }
674 let path = canonicalize(&root.join(relative))?;
675 if !path.starts_with(root) {
676 return Err(DbLifecycleError::Invalid(format!(
677 "seed {kind} path {} escapes its configured root",
678 relative.display()
679 )));
680 }
681 path.strip_prefix(project_root)
682 .map(Path::to_path_buf)
683 .map_err(|_| {
684 DbLifecycleError::Invalid(format!(
685 "seed {kind} path {} escapes the project",
686 relative.display()
687 ))
688 })
689}
690
691fn read_planned_seed_file(
692 project_root: &Path,
693 root: &Path,
694 relative: &Path,
695 expected_digest: &str,
696 seed_id: &str,
697 kind: &str,
698) -> Result<String, DbLifecycleError> {
699 if relative.is_absolute() {
700 return Err(DbLifecycleError::Invalid(format!(
701 "seed {seed_id} {kind} path must be project-relative"
702 )));
703 }
704 let path = canonicalize(&project_root.join(relative))?;
705 if !path.starts_with(root) {
706 return Err(DbLifecycleError::Invalid(format!(
707 "seed {seed_id} {kind} path escapes its configured root"
708 )));
709 }
710 let bytes = fs::read(&path).map_err(|source| DbLifecycleError::Io {
711 path: path.clone(),
712 source,
713 })?;
714 if sha256_hex(&bytes) != expected_digest {
715 return Err(DbLifecycleError::Invalid(format!(
716 "seed {seed_id} {kind} changed after planning"
717 )));
718 }
719 String::from_utf8(bytes)
720 .map_err(|_| DbLifecycleError::Invalid(format!("seed {seed_id} {kind} is not UTF-8")))
721}