Skip to main content

minco_db/
seed.rs

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}