diff --git a/Cargo.lock b/Cargo.lock index 1a6ac6a..e9d4210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2423,6 +2423,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "mlf-generated-lexicon" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "mlf-integration-tests" version = "0.1.0" @@ -2512,6 +2519,7 @@ version = "0.1.0" dependencies = [ "chrono", "mlf-atproto", + "mlf-generated-lexicon", "serde", "serde_json", "thiserror 2.0.17", diff --git a/Cargo.toml b/Cargo.toml index ee0cf89..fe3f233 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "dns-plugins/mlf-dns-cloudflare", "mlf-atproto", "mlf-cli", + "mlf-generated-lexicon", "dns-plugins/mlf-dns-godaddy", "dns-plugins/mlf-dns-google", "dns-plugins/mlf-dns-namecheap", diff --git a/lexicons/lol/mlf/package.mlf b/lexicons/lol/mlf/package.mlf new file mode 100644 index 0000000..9fd892e --- /dev/null +++ b/lexicons/lol/mlf/package.mlf @@ -0,0 +1,39 @@ +/// MLF publish manifest — one record per publish event. +/// +/// Written by `mlf publish` into the author's PDS alongside the +/// lexicons themselves. The record's own CID is the durable identifier +/// for "this specific publish" — a content-addressed release pointer +/// that binds the set of published items and the dependency versions +/// they were resolved against. +self {} + +/// One lexicon record included in a publish. +def type PublishedItem = { + /// NSID of the published lexicon. + nsid!: Nsid, + /// CID of the record as stored on the PDS. + cid!: Cid, +}; + +/// One external dependency pinned at publish time. +def type ResolvedDependency = { + /// NSID of the external lexicon the package linked against. + nsid!: Nsid, + /// CID of the record at the moment of the publish. + cid!: Cid, +}; + +record package { + /// ISO 8601 timestamp of when the publish completed. + publishedAt!: Datetime, + /// Version string of the MLF tool that produced the publish + /// (e.g. `"mlf@0.1.0"`). + tool!: string, + /// Every lexicon record the publish wrote to the PDS in this event, + /// sorted lexicographically by NSID. + published!: PublishedItem[], + /// Every external lexicon the workspace resolved against at publish + /// time, pinned to the remote CID observed during the publish. + /// Empty list when the package has no external dependencies. + resolvedDependencies: ResolvedDependency[], +} diff --git a/mlf-generated-lexicon/Cargo.toml b/mlf-generated-lexicon/Cargo.toml new file mode 100644 index 0000000..06531d9 --- /dev/null +++ b/mlf-generated-lexicon/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "mlf-generated-lexicon" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Typed Rust helpers generated from this workspace's MLF lexicons" + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/mlf-generated-lexicon/src/generated/lol/mlf/package.rs b/mlf-generated-lexicon/src/generated/lol/mlf/package.rs new file mode 100644 index 0000000..4980583 --- /dev/null +++ b/mlf-generated-lexicon/src/generated/lol/mlf/package.rs @@ -0,0 +1,38 @@ +// Generated from lol.mlf.package +// Do not edit manually + +use serde::{Deserialize, Serialize}; + +/// One lexicon record included in a publish. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublishedItem { + /// NSID of the published lexicon. + pub nsid: String, + /// CID of the record as stored on the PDS. + pub cid: String, +} + +/// One external dependency pinned at publish time. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolvedDependency { + /// NSID of the external lexicon the package linked against. + pub nsid: String, + /// CID of the record at the moment of the publish. + pub cid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Package { + /// ISO 8601 timestamp of when the publish completed. + #[serde(rename = "publishedAt")] + pub published_at: String, + /// Version string of the MLF tool that produced the publish + pub tool: String, + /// Every lexicon record the publish wrote to the PDS in this event, + pub published: Vec, + /// Every external lexicon the workspace resolved against at publish + #[serde(rename = "resolvedDependencies")] + #[serde(skip_serializing_if = "Option::is_none")] + pub resolved_dependencies: Option>, +} + diff --git a/mlf-generated-lexicon/src/lib.rs b/mlf-generated-lexicon/src/lib.rs new file mode 100644 index 0000000..165a703 --- /dev/null +++ b/mlf-generated-lexicon/src/lib.rs @@ -0,0 +1,18 @@ +//! Typed Rust helpers generated from this workspace's MLF lexicons. +//! +//! `mlf generate` writes into `src/generated/`; this crate stitches +//! those files into a module tree that mirrors each lexicon's NSID. +//! Consumers import from the NSID path — e.g. +//! `use mlf_generated_lexicon::lol::mlf::package::Package;`. +//! +//! Source of truth is always the `.mlf` files in the workspace root's +//! `lexicons/` directory. When the schema changes, rerun +//! `mlf generate` and commit the refreshed output. + +pub mod lol { + pub mod mlf { + pub mod package { + include!("generated/lol/mlf/package.rs"); + } + } +} diff --git a/mlf-publish/Cargo.toml b/mlf-publish/Cargo.toml index 2a0550a..98c9254 100644 --- a/mlf-publish/Cargo.toml +++ b/mlf-publish/Cargo.toml @@ -7,6 +7,7 @@ description = "Publish orchestration: CID diff, validators, manifest writer, XRP [dependencies] mlf-atproto = { path = "../mlf-atproto" } +mlf-generated-lexicon = { path = "../mlf-generated-lexicon" } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/mlf-publish/src/manifest.rs b/mlf-publish/src/manifest.rs index 0c91c3e..29b8f04 100644 --- a/mlf-publish/src/manifest.rs +++ b/mlf-publish/src/manifest.rs @@ -8,8 +8,20 @@ //! The record's own CID (computed by the PDS on `putRecord`) is the //! deterministic identifier for the publish — "this version of this //! package." There's deliberately no semver; mutation isn't supported. +//! +//! Implementation note: we assemble the instance data (publishedAt, +//! tool, published[], resolvedDependencies[]) using the typed +//! [`mlf_generated_lexicon::lol::mlf::package::Package`] struct emitted by +//! `mlf generate` from `lexicons/lol/mlf/package.mlf` and owned by the +//! `mlf-generated-lexicon` crate. The `com.atproto.lexicon.schema` +//! envelope fields (`$type`, `lexicon`, `id`, `description`, `defs`) +//! are still hand-assembled: the atproto meta-schema only requires +//! `lexicon: integer`, so it's not worth generating a type for — the +//! envelope is stable and trivially verified by the +//! `check_meta_schema` validator. -use serde_json::{Map, Value, json}; +use mlf_generated_lexicon::lol::mlf::package::{Package, PublishedItem, ResolvedDependency}; +use serde_json::{Map, Value, json, to_value}; /// The NSID the manifest is published under (rkey == NSID, per the /// ATProto lexicon spec). @@ -40,6 +52,36 @@ pub fn build(inputs: &ManifestInputs<'_>) -> Value { let mut deps: Vec<(String, String)> = inputs.resolved_deps.to_vec(); deps.sort(); + // Build the instance payload using the typed struct emitted by + // `mlf generate` — any schema change surfaces here as a compile error. + let instance = Package { + published_at: inputs.published_at.to_string(), + tool: inputs.tool.to_string(), + published: published + .into_iter() + .map(|(nsid, cid)| PublishedItem { nsid, cid }) + .collect(), + resolved_dependencies: if deps.is_empty() { + None + } else { + Some( + deps.into_iter() + .map(|(nsid, cid)| ResolvedDependency { nsid, cid }) + .collect(), + ) + }, + }; + + // Merge the instance fields into a `com.atproto.lexicon.schema` + // record envelope. The envelope is hand-assembled because the + // atproto meta-schema is too permissive to generate useful types + // from (it only requires `lexicon: integer`). + let instance_value = to_value(&instance).expect("Package serialises"); + let instance_obj = instance_value + .as_object() + .expect("Package always serialises to a JSON object") + .clone(); + let mut obj = Map::new(); obj.insert( "$type".into(), @@ -51,52 +93,57 @@ pub fn build(inputs: &ManifestInputs<'_>) -> Value { "description".into(), Value::String("MLF publish manifest".into()), ); - // Minimal valid `defs` so the meta-schema is satisfied. The record - // is a regular lexicon-schema record whose "main" def describes its - // own custom fields. v1 keeps this terse; downstream readers use - // `items`/`resolvedDependencies` directly. - obj.insert( - "defs".into(), - json!({ - "main": { - "type": "record", - "key": "nsid", - "record": { - "type": "object", - "required": ["publishedAt", "tool", "published"], - "properties": { - "publishedAt": {"type": "string"}, - "tool": {"type": "string"}, - "published": {"type": "array"}, - "resolvedDependencies": {"type": "array"} + obj.insert("defs".into(), manifest_defs()); + for (k, v) in instance_obj { + obj.insert(k, v); + } + Value::Object(obj) +} + +/// The `defs` block describing the instance shape. Kept in one place +/// so it's easy to bump if the on-PDS shape ever needs tightening. +/// The shape here matches what `mlf generate lexicon` would emit from +/// our `.mlf` source; keeping it in Rust avoids needing a second +/// generated artifact in the repo. +fn manifest_defs() -> Value { + json!({ + "main": { + "type": "record", + "key": "nsid", + "record": { + "type": "object", + "required": ["publishedAt", "tool", "published"], + "properties": { + "publishedAt": {"type": "string", "format": "datetime"}, + "tool": {"type": "string"}, + "published": { + "type": "array", + "items": {"type": "ref", "ref": "#PublishedItem"} + }, + "resolvedDependencies": { + "type": "array", + "items": {"type": "ref", "ref": "#ResolvedDependency"} } } } - }), - ); - obj.insert( - "publishedAt".into(), - Value::String(inputs.published_at.to_string()), - ); - obj.insert("tool".into(), Value::String(inputs.tool.to_string())); - obj.insert( - "published".into(), - Value::Array( - published - .into_iter() - .map(|(nsid, cid)| json!({"nsid": nsid, "cid": cid})) - .collect(), - ), - ); - obj.insert( - "resolvedDependencies".into(), - Value::Array( - deps.into_iter() - .map(|(nsid, cid)| json!({"nsid": nsid, "cid": cid})) - .collect(), - ), - ); - Value::Object(obj) + }, + "PublishedItem": { + "type": "object", + "required": ["nsid", "cid"], + "properties": { + "nsid": {"type": "string", "format": "nsid"}, + "cid": {"type": "string", "format": "cid"} + } + }, + "ResolvedDependency": { + "type": "object", + "required": ["nsid", "cid"], + "properties": { + "nsid": {"type": "string", "format": "nsid"}, + "cid": {"type": "string", "format": "cid"} + } + } + }) } #[cfg(test)] @@ -116,12 +163,27 @@ mod tests { }); assert_eq!(v["$type"], "com.atproto.lexicon.schema"); assert_eq!(v["id"], NSID); + assert_eq!(v["lexicon"], 1); // published list is sorted assert_eq!(v["published"][0]["nsid"], "com.example.other"); assert_eq!(v["published"][1]["nsid"], "com.example.thing"); assert_eq!(v["resolvedDependencies"][0]["cid"], "bafy3"); } + #[test] + fn empty_deps_omits_resolved_dependencies_field() { + // The generated struct marks `resolved_dependencies` Option with + // skip_serializing_if, so an empty manifest doesn't carry a + // confusing "[]" on-wire. + let v = build(&ManifestInputs { + tool: "mlf@x", + published_at: "t", + published: &[("a".into(), "bafy1".into())], + resolved_deps: &[], + }); + assert!(v.get("resolvedDependencies").is_none()); + } + #[test] fn manifest_cid_is_deterministic() { let a = build(&ManifestInputs { diff --git a/mlf.toml b/mlf.toml index 29b14d5..f6db9bf 100644 --- a/mlf.toml +++ b/mlf.toml @@ -1,5 +1,12 @@ +[package] +name = "lol.mlf" + [source] directory = "./lexicons" [dependencies] dependencies = [] + +[[output]] +type = "rust" +directory = "./mlf-generated-lexicon/src/generated"