Skip to main content

generate_portability_fixtures/
generate_portability_fixtures.rs

1#![forbid(unsafe_code)]
2//! Deterministic generator for the adapter portability manifests and receipts.
3
4use std::env;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use riffdb_application::{
9    AdapterConformanceManifest, ApplicationPortabilityManifest,
10    ApplicationPortabilityManifestInput, ApplicationPortabilityValidation,
11    ApplicationReimportReceipt, InstallationArtifactKind, InstallationSymbol, PortableOmission,
12    PortableOmissionClass, PortableOmissionReason, PortableRecordClass, PortableRecordMapping,
13    PortableReimportStrategy, ReimportMappingResult, ReimportObservation,
14    ReimportObservationParameter, ReimportObservationResult,
15};
16use riffdb_contract_ir::ContractBundle;
17use riffdb_types::{
18    ApplicationExportManifestHash, CanonicalValue, DatabaseId, QueryModuleHash,
19    hash_generated_artifact,
20};
21
22struct Domain<'a> {
23    name: &'a str,
24    application_root: &'a str,
25    mappings: &'a [(&'a str, &'a str)],
26    query: &'a str,
27    parameters: &'a [(&'a str, u16)],
28    empty_collection: Option<&'a str>,
29    seed: u8,
30}
31
32const DOMAINS: [Domain<'static>; 5] = [
33    Domain {
34        name: "openfga",
35        application_root: "fixtures/adapters/operational-conformance",
36        mappings: &[("FgaTuple", "ReconstituteFgaTuples")],
37        query: "ListFgaTuples",
38        parameters: &[("store_id", 10)],
39        empty_collection: Some("tuples"),
40        seed: 1,
41    },
42    Domain {
43        name: "better-auth",
44        application_root: "fixtures/adapters/better-auth",
45        mappings: &[
46            ("User", "ReconstituteUsers"),
47            ("Account", "ReconstituteAccounts"),
48            ("Session", "ReconstituteSessions"),
49            ("VerificationToken", "ReconstituteVerificationTokens"),
50        ],
51        query: "GetSession",
52        parameters: &[("organization_id", 30), ("user_id", 31), ("session_id", 32)],
53        empty_collection: None,
54        seed: 2,
55    },
56    Domain {
57        name: "mlflow",
58        application_root: "fixtures/adapters/mlflow",
59        mappings: &[("ScheduledRun", "ReconstituteScheduledRuns")],
60        query: "DueRun",
61        parameters: &[("organization_id", 50), ("run_id", 51)],
62        empty_collection: None,
63        seed: 3,
64    },
65    Domain {
66        name: "woodpecker",
67        application_root: "fixtures/adapters/woodpecker",
68        mappings: &[("ScheduledPipeline", "ReconstituteScheduledPipelines")],
69        query: "GetPipeline",
70        parameters: &[("organization_id", 60), ("pipeline_id", 61)],
71        empty_collection: None,
72        seed: 4,
73    },
74    Domain {
75        name: "payload",
76        application_root: "fixtures/adapters/operational-conformance",
77        mappings: &[("Document", "ReconstituteDocuments")],
78        query: "ListDraftDocuments",
79        parameters: &[("site_id", 40)],
80        empty_collection: Some("documents"),
81        seed: 5,
82    },
83];
84
85fn main() {
86    let write = env::args().nth(1).as_deref() == Some("--write");
87    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
88    for domain in DOMAINS {
89        generate_domain(&root, &domain, write);
90    }
91}
92
93fn generate_domain(root: &Path, domain: &Domain<'_>, write: bool) {
94    let bundle = ContractBundle::decode(
95        &fs::read(
96            root.join(domain.application_root)
97                .join("generated/riffdb.contract.bundle"),
98        )
99        .expect("read adapter contract bundle"),
100    )
101    .expect("decode adapter contract bundle");
102    let adapter = AdapterConformanceManifest::decode_canonical(
103        &fs::read(
104            root.join("fixtures/adapters/conformance")
105                .join(domain.name)
106                .join("adapter.conformance.json"),
107        )
108        .expect("read adapter manifest"),
109    )
110    .expect("decode adapter manifest");
111    let observation_hash = empty_observation_hash(domain.empty_collection);
112    let module_hash = adapter
113        .input()
114        .artifacts
115        .iter()
116        .find(|artifact| artifact.kind() == InstallationArtifactKind::QueryModule)
117        .map(|artifact| QueryModuleHash::from_bytes(*artifact.content_hash().as_bytes()))
118        .expect("adapter query module");
119    let manifest = ApplicationPortabilityManifest::compile(ApplicationPortabilityManifestInput {
120        adapter_manifest_hash: adapter.identity(),
121        contract_lineage: bundle.lineage().clone(),
122        contract_version: bundle.contract_version(),
123        contract_bundle_hash: bundle.bundle_hash(),
124        mappings: domain
125            .mappings
126            .iter()
127            .map(|(entity, command)| {
128                PortableRecordMapping::new(
129                    PortableRecordClass::Entity,
130                    symbol(entity),
131                    PortableReimportStrategy::reimport_command(symbol(command)),
132                )
133            })
134            .collect(),
135        omissions: vec![
136            PortableOmission::new(
137                PortableOmissionClass::Provenance,
138                None,
139                PortableOmissionReason::HistoricalRecordsNotRegenerable,
140            )
141            .expect("provenance omission"),
142            PortableOmission::new(
143                PortableOmissionClass::PublicAudit,
144                None,
145                PortableOmissionReason::HistoricalRecordsNotRegenerable,
146            )
147            .expect("audit omission"),
148        ],
149        observations: vec![
150            ReimportObservation::new_with_parameters(
151                symbol("application_observation"),
152                symbol(domain.query),
153                module_hash,
154                domain
155                    .parameters
156                    .iter()
157                    .map(|(name, suffix)| {
158                        ReimportObservationParameter::new(
159                            symbol(name),
160                            CanonicalValue::Uuid(application_uuid(*suffix)),
161                        )
162                        .expect("observation parameter")
163                    })
164                    .collect(),
165                observation_hash,
166                500,
167            )
168            .expect("observation"),
169        ],
170    })
171    .expect("compile portability manifest");
172    manifest
173        .validate_compiled_contract(&bundle)
174        .expect("compiled contract mapping");
175    assert_eq!(manifest.input().adapter_manifest_hash, adapter.identity());
176    assert_eq!(
177        manifest.input().contract_lineage,
178        adapter.input().contract_lineage
179    );
180    assert!(adapter.input().evolution.iter().any(|evolution| {
181        evolution.successor().version() == manifest.input().contract_version
182            && evolution.successor().bundle_hash() == manifest.input().contract_bundle_hash
183    }));
184    manifest
185        .validate_adapter_conformance(&adapter)
186        .unwrap_or_else(|error| panic!("{} adapter mapping: {error:?}", domain.name));
187
188    let outcome_hash = hash_generated_artifact(format!("{}-outcomes-v1", domain.name).as_bytes());
189    let destination = root.join("fixtures/export").join(domain.name);
190    let receipt = reimport_receipt(&manifest, domain, outcome_hash);
191    update(
192        destination.join("portability-manifest-v3.json"),
193        manifest.canonical_bytes(),
194        write,
195    );
196    let legacy_manifest_path = destination.join("portability-manifest-v2.json");
197    if legacy_manifest_path.is_file() {
198        let legacy_manifest = ApplicationPortabilityManifest::decode_canonical(
199            &fs::read(legacy_manifest_path).expect("read V2 portability manifest"),
200        )
201        .expect("decode V2 portability manifest");
202        let legacy_receipt = reimport_receipt(&legacy_manifest, domain, outcome_hash);
203        update(
204            destination.join("reimport-receipt-v2.json"),
205            legacy_receipt.canonical_bytes(),
206            write,
207        );
208    }
209    update(
210        destination.join("reimport-receipt-v3.json"),
211        receipt.canonical_bytes(),
212        write,
213    );
214}
215
216fn reimport_receipt(
217    manifest: &ApplicationPortabilityManifest,
218    domain: &Domain<'_>,
219    outcome_hash: riffdb_types::GeneratedArtifactHash,
220) -> ApplicationReimportReceipt {
221    ApplicationReimportReceipt::reconcile(
222        manifest,
223        ApplicationExportManifestHash::from_bytes(
224            *hash_generated_artifact(format!("{}-export-v1", domain.name).as_bytes()).as_bytes(),
225        ),
226        database_id(domain.seed),
227        manifest
228            .input()
229            .mappings
230            .iter()
231            .map(|mapping| {
232                ReimportMappingResult::new(
233                    mapping.class(),
234                    mapping.symbol().clone(),
235                    2,
236                    2,
237                    0,
238                    outcome_hash,
239                )
240                .expect("mapping result")
241            })
242            .collect(),
243        manifest
244            .input()
245            .observations
246            .iter()
247            .map(|observation| {
248                ReimportObservationResult::new(
249                    observation.name().clone(),
250                    observation.expected_hash(),
251                )
252            })
253            .collect(),
254    )
255    .expect("reimport receipt")
256}
257
258fn symbol(value: &str) -> InstallationSymbol {
259    InstallationSymbol::new(value).expect("fixture symbol")
260}
261
262fn database_id(seed: u8) -> DatabaseId {
263    DatabaseId::from_bytes([0, 0, 0, 0, 0, seed, 0x70, 0, 0x80, 0, 0, 0, 0, 0, 0, seed])
264        .expect("fixture database ID")
265}
266
267fn application_uuid(suffix: u16) -> [u8; 16] {
268    let mut value = [
269        0x01, 0x8f, 0x0f, 0x8b, 0x7c, 0x6d, 0x7e, 0x31, 0x8a, 0x4f, 0, 0, 0, 0, 0, 0,
270    ];
271    value[14..].copy_from_slice(&suffix.to_be_bytes());
272    value
273}
274
275fn empty_observation_hash(collection: Option<&str>) -> riffdb_types::GeneratedArtifactHash {
276    let mut bytes = b"riffdb.reimport-observation/v1\0".to_vec();
277    let outcome = if collection.is_some() {
278        "Found"
279    } else {
280        "Missing"
281    };
282    push_bytes(&mut bytes, outcome.as_bytes());
283    match collection {
284        Some(name) => {
285            bytes.extend_from_slice(&1_u32.to_be_bytes());
286            push_bytes(&mut bytes, name.as_bytes());
287            bytes.push(4); // SymbolicResultField::Many.
288            bytes.extend_from_slice(&0_u32.to_be_bytes());
289        }
290        None => bytes.extend_from_slice(&0_u32.to_be_bytes()),
291    }
292    hash_generated_artifact(&bytes)
293}
294
295fn push_bytes(target: &mut Vec<u8>, value: &[u8]) {
296    target.extend_from_slice(
297        &u32::try_from(value.len())
298            .expect("fixture value length")
299            .to_be_bytes(),
300    );
301    target.extend_from_slice(value);
302}
303
304fn update(path: PathBuf, expected: &[u8], write: bool) {
305    if write {
306        fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture parent");
307        fs::write(&path, expected).expect("write fixture");
308        return;
309    }
310    let actual = fs::read(&path).unwrap_or_default();
311    assert_eq!(actual, expected, "stale fixture: {}", path.display());
312}