diff --git a/Cargo.lock b/Cargo.lock index 9eb299a..208ed97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,7 +120,7 @@ dependencies = [ [[package]] name = "aimdb-data-contracts" -version = "0.3.0" +version = "0.4.0" dependencies = [ "aimdb-core", "aimdb-derive", diff --git a/Makefile b/Makefile index da9602a..9cc0ae5 100644 --- a/Makefile +++ b/Makefile @@ -63,11 +63,13 @@ help: build: @printf "$(GREEN)Building AimDB (all valid combinations)...$(NC)\n" @printf "$(YELLOW) → Building aimdb-data-contracts (std)$(NC)\n" - cargo build --package aimdb-data-contracts --features "std,simulatable,migratable,observable" + cargo build --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json" @printf "$(YELLOW) → Building aimdb-data-contracts (no_std)$(NC)\n" cargo build --package aimdb-data-contracts --no-default-features --features alloc - @printf "$(YELLOW) → Building aimdb-data-contracts (no_std + linkable + migratable)$(NC)\n" - cargo build --package aimdb-data-contracts --no-default-features --features alloc,linkable,migratable + @printf "$(YELLOW) → Building aimdb-data-contracts (no_std + format-neutral linkable)$(NC)\n" + cargo build --package aimdb-data-contracts --no-default-features --features alloc,linkable + @printf "$(YELLOW) → Building aimdb-data-contracts (no_std + linkable-json + migratable)$(NC)\n" + cargo build --package aimdb-data-contracts --no-default-features --features alloc,linkable-json,migratable @printf "$(YELLOW) → Building aimdb-core (no_std + alloc)$(NC)\n" cargo build --package aimdb-core --no-default-features --features alloc @printf "$(YELLOW) → Building aimdb-core (std platform)$(NC)\n" @@ -112,9 +114,11 @@ build: test: @printf "$(GREEN)Running all tests (valid combinations)...$(NC)\n" @printf "$(YELLOW) → Testing aimdb-data-contracts (std)$(NC)\n" - cargo test --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable" - @printf "$(YELLOW) → Testing aimdb-data-contracts (no_std + alloc + linkable + migratable)$(NC)\n" - cargo test --package aimdb-data-contracts --no-default-features --features alloc,linkable,migratable + cargo test --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json" + @printf "$(YELLOW) → Testing aimdb-data-contracts (no_std + alloc + format-neutral linkable)$(NC)\n" + cargo test --package aimdb-data-contracts --no-default-features --features alloc,linkable + @printf "$(YELLOW) → Testing aimdb-data-contracts (no_std + alloc + linkable-json + migratable)$(NC)\n" + cargo test --package aimdb-data-contracts --no-default-features --features alloc,linkable-json,migratable @printf "$(YELLOW) → Testing aimdb-core (no_std + alloc)$(NC)\n" cargo test --package aimdb-core --no-default-features --features alloc @printf "$(YELLOW) → Testing aimdb-core (std platform)$(NC)\n" @@ -205,11 +209,13 @@ clippy: @printf "$(YELLOW) → Clippy on aimdb-derive$(NC)\n" cargo clippy --package aimdb-derive --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-data-contracts (std)$(NC)\n" - cargo clippy --package aimdb-data-contracts --features "std,simulatable,migratable,observable" --all-targets -- -D warnings + cargo clippy --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-data-contracts (no_std + alloc)$(NC)\n" cargo clippy --package aimdb-data-contracts --no-default-features --features alloc -- -D warnings - @printf "$(YELLOW) → Clippy on aimdb-data-contracts (no_std + alloc + linkable + migratable)$(NC)\n" - cargo clippy --package aimdb-data-contracts --no-default-features --features alloc,linkable,migratable -- -D warnings + @printf "$(YELLOW) → Clippy on aimdb-data-contracts (no_std + alloc + format-neutral linkable)$(NC)\n" + cargo clippy --package aimdb-data-contracts --no-default-features --features alloc,linkable --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on aimdb-data-contracts (no_std + alloc + linkable-json + migratable)$(NC)\n" + cargo clippy --package aimdb-data-contracts --no-default-features --features alloc,linkable-json,migratable --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-core (no_std + alloc)$(NC)\n" cargo clippy --package aimdb-core --no-default-features --features alloc --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-core (no_std + alloc + remote)$(NC)\n" @@ -283,7 +289,7 @@ doc: @mkdir -p target/doc-final/cloud @mkdir -p target/doc-final/embedded @printf "$(YELLOW) → Building cloud/edge documentation$(NC)\n" - cargo doc --package aimdb-data-contracts --features "std,simulatable,migratable,observable" --no-deps + cargo doc --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json" --no-deps cargo doc --package aimdb-core --features "std,tracing,observability" --no-deps cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" --no-deps cargo doc --package aimdb-sync --no-deps @@ -330,8 +336,10 @@ test-embedded: @printf "$(BLUE)Testing embedded/MCU cross-compilation compatibility...$(NC)\n" @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc - @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc + linkable + migratable) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc,linkable,migratable + @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc + format-neutral linkable) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc,linkable + @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc + linkable-json + migratable) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc,linkable-json,migratable @printf "$(YELLOW) → Checking weather-mesh-common (no_std migratable, real TemperatureV1ToV2 chain, no direct serde_json dep) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package weather-mesh-common --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features migratable @printf "$(YELLOW) → Checking aimdb-core (no_std minimal) on thumbv7em-none-eabihf target$(NC)\n" diff --git a/README.md b/README.md index f30d8ea..1900aab 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Every capability is an opt-in trait on your schema type: implement it and **exac | Contract | Implement when… | Verb it unlocks | Tier | | --- | --- | --- | --- | -| [`Linkable`](https://aimdb.dev/blog/connectors-where-aimdb-meets-the-real-world) | the record is mirrored to/from an endpoint (MQTT, KNX, serial, UDS…) | `.linked_from(url)` / `.linked_to(url)` (`#[derive(Linkable)]` for JSON) | wire (prod) | +| [`Linkable`](https://aimdb.dev/blog/connectors-where-aimdb-meets-the-real-world) | the record is mirrored to/from an endpoint (MQTT, KNX, serial, UDS…) | `.linked_from(url)` / `.linked_to(url)` (`linkable-json` provides the JSON derive; codegen supports Postcard) | wire (prod) | | [`Streamable`](https://aimdb.dev/blog/streamable-crossing-boundaries) | the record streams to browsers as schema-named JSON | ws-connector `.register::()` | wire (prod) | | [`Migratable`](https://aimdb.dev/blog/schema-migration-without-ceremony) | the schema evolved across versions | `migration_chain!` | wire (prod) | | `Settable` | sync code outside AimDB sets the value | `SyncProducer::set_value(v)` | wire (prod) | diff --git a/aimdb-codegen/CHANGELOG.md b/aimdb-codegen/CHANGELOG.md index 1169fee..26553df 100644 --- a/aimdb-codegen/CHANGELOG.md +++ b/aimdb-codegen/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Postcard codegen is now exercised end to end by `make codegen-drift`: a generated Postcard-only common crate roundtrips on the host, cross-compiles for `thumbv7em-none-eabihf`, and proves its normal dependency graph is JSON-free. A generated mixed JSON + Postcard crate is also compiled so codec feature/dependency drift fails in CI (#155). + ### Fixed - Generated `Cargo.toml` for manifests mixing `serialization = "json"` and `serialization = "postcard"` records omitted the `serde_json` dependency (postcard presence suppressed it), so the generated crate failed to compile under the `std` feature. `serde_json` is now emitted whenever any record uses JSON serialization, independent of postcard (#155). diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index 599d2ce..ee9ea6b 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -2291,6 +2291,29 @@ description = "Reading value" ); } + #[test] + fn linkable_impl_postcard_generated() { + let mut state = ArchitectureState::from_toml(MIXED_CODEC_TOML).unwrap(); + state.records.retain(|r| r.name == "PostcardReading"); + let rust = generate_schema_rs(&state); + assert!( + rust.contains("impl Linkable for PostcardReadingValue"), + "Missing Postcard Linkable impl:\n{rust}" + ); + assert!( + rust.contains("postcard::to_allocvec(self)"), + "Missing postcard serializer:\n{rust}" + ); + assert!( + rust.contains("postcard::from_bytes(data)"), + "Missing postcard deserializer:\n{rust}" + ); + assert!( + !rust.contains("serde_json"), + "Postcard-only schema should not reference serde_json:\n{rust}" + ); + } + #[test] fn generate_lib_rs_output() { let lib = generate_lib_rs(); diff --git a/aimdb-data-contracts/CHANGELOG.md b/aimdb-data-contracts/CHANGELOG.md index c4d41cb..ca7d847 100644 --- a/aimdb-data-contracts/CHANGELOG.md +++ b/aimdb-data-contracts/CHANGELOG.md @@ -12,13 +12,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`Simulatable` reshaped for compile-time-only simulation.** `simulate(config: &SimulationConfig, ...)` → `simulate(params: &Self::Params, ...)` with a new associated `type Params`. `SimulationConfig` (including its runtime `enabled` gate) and `SimulationParams` are removed; `SimProfile

{ interval_ms, params }` and off-the-shelf `RandomWalkParams` replace them (`type Params = RandomWalkParams` is the migration path for existing scalar-walk impls). `simulatable` now also requires `aimdb-core` (for the new `SimulatableRegistrarExt`) and `rand` drops the `std_rng` feature (RNG is always caller-supplied). - **`Observable` loses `ICON` and `format_log()`.** The trait is now numeric-projection-only: `type Signal`, `const SIGNAL` (defaults to `Self::NAME`), `const UNIT`, `fn signal()`. Presentation moved to `ObservableRegistrarExt::log(node_id)`. - **`Settable` moves behind a new `settable` feature** (was compiled unconditionally) for tier symmetry with the other wire contracts. +- **`linkable` is now format-neutral.** It enables only the `Linkable` trait, registrar verbs, `alloc`, and `aimdb-core`; it no longer pulls `serde_json` or re-exports the JSON `#[derive(Linkable)]`. Existing derive users must enable the new `linkable-json` feature. Manual Postcard/custom implementations stay on base `linkable`, so their normal dependency graph is JSON-free (#155). ### Added - `SimulatableRegistrarExt::simulate(profile, rng)` — installs a `.source()` loop emitting `T::simulate(...)` on a timer. - `ObservableRegistrarExt::observe()` — feeds `T::signal()` into a core signal gauge (last/min/max/mean), surfaced on `record.list`/`record.get`/stage profiling; `ObservableRegistrarExt::log(node_id)` for the console-logging path (replaces the old `format_log`). - `LinkableRegistrarExt::linked_from(url)` / `linked_to(url)` — one-line `.link_from()`/`.link_to()` wiring defaulted to `T::from_bytes`/`T::to_bytes`. `linkable` now also requires `aimdb-core`. -- `#[derive(Linkable)]` (via `aimdb-derive`) — emits a JSON `Linkable` impl (`serde_json::to_vec`/`from_slice`), `no_std + alloc` compatible. +- `linkable-json` — layers `serde_json` and the JSON `#[derive(Linkable)]` convenience over the format-neutral `linkable` contract; `no_std + alloc` compatible. ### Changed diff --git a/aimdb-data-contracts/Cargo.toml b/aimdb-data-contracts/Cargo.toml index f8e5ff6..782e3a0 100644 --- a/aimdb-data-contracts/Cargo.toml +++ b/aimdb-data-contracts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aimdb-data-contracts" -version = "0.3.0" +version = "0.4.0" edition.workspace = true authors.workspace = true license.workspace = true @@ -14,9 +14,12 @@ categories = ["embedded", "no-std"] default = ["std"] alloc = ["serde/alloc"] std = ["alloc", "serde/std", "serde_json?/std"] -# The ext trait needs core's registrar/connector-builder types (same wiring as -# `observable`); `dep:aimdb-derive` powers `#[derive(Linkable)]`. -linkable = ["alloc", "serde_json", "aimdb-core", "dep:aimdb-derive"] +# Format-neutral connector contract plus registrar verbs. Concrete codecs live +# at the application/codegen edge, so Postcard/custom builds stay JSON-free. +linkable = ["alloc", "aimdb-core"] +# JSON convenience layered over the format-neutral contract. The derive emits +# serde_json calls through this crate's private re-export. +linkable-json = ["linkable", "serde_json", "dep:aimdb-derive"] # Dev-tier — never a default feature. The ext trait needs core's registrar, # same coupling `observable` has. `rand` is the tracer CI uses to prove a # production graph is sim-free (`make check-no-sim`). diff --git a/aimdb-data-contracts/src/lib.rs b/aimdb-data-contracts/src/lib.rs index f6f5c57..688cb1c 100644 --- a/aimdb-data-contracts/src/lib.rs +++ b/aimdb-data-contracts/src/lib.rs @@ -57,11 +57,12 @@ extern crate self as aimdb_data_contracts; /// Re-exports used by macro-generated code, kept out of the crate's own /// namespace so a caller's own `serde_json`/`alloc` (if any) never shadows -/// or gets shadowed by them — see `migration_chain!`'s expansion. +/// or gets shadowed by them — see the `Linkable` and `migration_chain!` +/// expansions. #[doc(hidden)] pub mod __private { pub extern crate alloc; - #[cfg(any(feature = "linkable", feature = "migratable"))] + #[cfg(any(feature = "linkable-json", feature = "migratable"))] pub use serde_json; } @@ -74,13 +75,14 @@ mod linkable; #[cfg(feature = "linkable")] pub use linkable::LinkableRegistrarExt; -/// `#[derive(Linkable)]` — see [`Linkable`] and `aimdb_derive::Linkable`'s docs. +/// JSON `#[derive(Linkable)]` (feature `linkable-json`) — see [`Linkable`] and +/// `aimdb_derive::Linkable`'s docs. /// /// Re-exported under the same name as the trait, following the trait+derive /// pairing convention (`serde::Serialize`, `aimdb_derive::RecordKey`) — proc-macro /// derive names live in a separate namespace from traits, so this is not a /// conflict. -#[cfg(feature = "linkable")] +#[cfg(feature = "linkable-json")] pub use aimdb_derive::Linkable; #[cfg(feature = "observable")] @@ -222,12 +224,16 @@ pub trait Observable: SchemaType { /// Types that can be serialized/deserialized for connector links. /// /// Implement this trait, then call -/// [`LinkableRegistrarExt::linked_from`](linkable::LinkableRegistrarExt::linked_from) / +/// [`LinkableRegistrarExt::linked_from`] / /// [`linked_to`](linkable::LinkableRegistrarExt::linked_to) to wire `link_from` /// / `link_to` in AimDB connectors (MQTT, KNX, etc.) with the codec defaulted to /// `from_bytes`/`to_bytes`. This provides the wire format for transporting /// schema types across network boundaries. /// +/// The `linkable` feature is format-neutral. Enable `linkable-json` for the +/// JSON `#[derive(Linkable)]` convenience, or implement these methods directly +/// for Postcard and other shared-contract formats. +/// /// # Example /// /// Not compiled: the snippet needs `aimdb-core`'s builder types in scope. diff --git a/aimdb-data-contracts/tests/linkable_derive.rs b/aimdb-data-contracts/tests/linkable_derive.rs index 9343406..2733e29 100644 --- a/aimdb-data-contracts/tests/linkable_derive.rs +++ b/aimdb-data-contracts/tests/linkable_derive.rs @@ -5,7 +5,7 @@ //! `::aimdb_data_contracts::...` paths (matching the `migration_chain!` / //! `RecordKey` precedent), which only resolve from outside the defining crate. -#![cfg(feature = "linkable")] +#![cfg(feature = "linkable-json")] use aimdb_data_contracts::{Linkable, SchemaType}; use serde::{Deserialize, Serialize}; diff --git a/aimdb-derive/src/lib.rs b/aimdb-derive/src/lib.rs index d65a51b..bd9f6f4 100644 --- a/aimdb-derive/src/lib.rs +++ b/aimdb-derive/src/lib.rs @@ -46,7 +46,7 @@ pub fn migration_chain(input: TokenStream) -> TokenStream { /// Emits `from_bytes`/`to_bytes` via `serde_json::from_slice`/`to_vec` — the /// boilerplate every hand-written `impl Linkable` for a JSON-wire type repeats. /// Requires `T: Serialize + DeserializeOwned` (a normal -/// compile error surfaces if it's missing) and the `linkable` feature of +/// compile error surfaces if it's missing) and the `linkable-json` feature of /// `aimdb-data-contracts` (for the `Linkable` trait and its `__private::serde_json` /// re-export). Binary formats (e.g. KNX DPT codecs) still implement `Linkable` /// by hand — this derive is JSON-only. diff --git a/aimdb-sync/Cargo.toml b/aimdb-sync/Cargo.toml index 1bc8f3e..665cc2d 100644 --- a/aimdb-sync/Cargo.toml +++ b/aimdb-sync/Cargo.toml @@ -23,7 +23,7 @@ thiserror = "1.0" # Optional: Settable::set (feature `data-contracts`) for the `set_value` family. # aimdb-sync depends on the contracts crate, never the reverse — the contracts # crate has no `sync` feature. -aimdb-data-contracts = { path = "../aimdb-data-contracts", version = "0.3.0", optional = true, default-features = false, features = [ +aimdb-data-contracts = { path = "../aimdb-data-contracts", version = "0.4.0", optional = true, default-features = false, features = [ "settable", ] } diff --git a/aimdb-wasm-adapter/Cargo.toml b/aimdb-wasm-adapter/Cargo.toml index 9b516f7..625aa2d 100644 --- a/aimdb-wasm-adapter/Cargo.toml +++ b/aimdb-wasm-adapter/Cargo.toml @@ -35,7 +35,7 @@ aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = fal aimdb-ws-protocol = { version = "0.1.0", path = "../aimdb-ws-protocol" } # Data contracts (alloc only — no std) -aimdb-data-contracts = { version = "0.3.0", path = "../aimdb-data-contracts", default-features = false, features = [ +aimdb-data-contracts = { version = "0.4.0", path = "../aimdb-data-contracts", default-features = false, features = [ "alloc", ] } diff --git a/aimdb-websocket-connector/Cargo.toml b/aimdb-websocket-connector/Cargo.toml index fe7a47a..e4ba3a0 100644 --- a/aimdb-websocket-connector/Cargo.toml +++ b/aimdb-websocket-connector/Cargo.toml @@ -33,7 +33,7 @@ tracing = ["dep:tracing"] [dependencies] aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false } -aimdb-data-contracts = { version = "0.3.0", path = "../aimdb-data-contracts", default-features = false } +aimdb-data-contracts = { version = "0.4.0", path = "../aimdb-data-contracts", default-features = false } aimdb-ws-protocol = { version = "0.1.0", path = "../aimdb-ws-protocol" } # Async runtime diff --git a/docs/design/024-M11-codegen-common-crate.md b/docs/design/024-M11-codegen-common-crate.md index f34c15e..bac0c17 100644 --- a/docs/design/024-M11-codegen-common-crate.md +++ b/docs/design/024-M11-codegen-common-crate.md @@ -713,8 +713,11 @@ impl for connector wiring. ## 11. Open Questions 1. **Should `postcard` be a first-class serialisation target?** - It's the natural choice for `no_std` / Embassy. The TOML field supports it, - but implementation can be deferred to Phase 2+. + *Resolved — yes.* `serialization = "postcard"` emits + `postcard::to_allocvec` / `postcard::from_bytes`. Issue #155 split the + format-neutral `linkable` feature from the `linkable-json` convenience and + added host roundtrip, mixed-codec compile, JSON-free dependency, and + `thumbv7em-none-eabihf` drift checks for the generated Postcard path. 2. **Should the codegen generate `Debug` formatting for key enums?** Currently missing from the derive list. The hand-written crates include it. diff --git a/docs/design/039-no-std-migrations.md b/docs/design/039-no-std-migrations.md index 1fab338..3c084de 100644 --- a/docs/design/039-no-std-migrations.md +++ b/docs/design/039-no-std-migrations.md @@ -63,6 +63,11 @@ migratable = ["alloc", "serde_json"] # was: ["std", "serde_json"] serde_json = { workspace = true, optional = true } # was: local, default-features on ``` +> **Post-#155 update:** this block records the feature shape at the time of +> design 039. Current `linkable` is format-neutral (`alloc + aimdb-core`), while +> the JSON dependency and JSON derive live under `linkable-json`. `migratable` +> still owns its serde_json dependency as described here. + Notes: - `serde_json?/std` (weak dependency feature) keeps std builds on serde_json's std implementation without forcing the dependency on. @@ -136,7 +141,7 @@ serde_json = { workspace = true, optional = true } # was: version = - `linkable` keeps its `serde_json` because the `Linkable` impls in [`temperature.rs`](../../examples/weather-mesh-demo/weather-mesh-common/src/contracts/temperature.rs) (`from_bytes`/`to_bytes`, and the migration-backed `TemperatureV2` impl) call `serde_json::{from_slice, to_vec}` in hand-written code — the macro fix does not reach them. Switching the dep to the workspace pin (`alloc`, no default `std`) makes that path `no_std`-clean too, so this step also unblocks `linkable` on-target for the demo, not just `migratable`. - No source change in `temperature.rs` is required; the hand-written `serde_json` calls resolve against the now-`no_std` consumer dep. -Scope note: criterion 3's "no direct `serde_json` dependency" is literally true only for the `migratable`-without-`linkable` build — `linkable` genuinely serializes JSON by hand and will always carry the dep. The `weather-mesh-common` target lane in W3 builds exactly that configuration (`--features migratable`, no `linkable`), so `serde_json` is absent from its dependency graph. +Scope note: for the `weather-mesh-common` fixture, criterion 3's "no direct `serde_json` dependency" is literally true only for the `migratable`-without-`linkable` build — that crate's hand-written JSON `Linkable` impls genuinely require its direct dependency. After #155 the base `aimdb-data-contracts/linkable` feature itself is JSON-free; the weather fixture still opts into JSON explicitly. The W3 lane builds `--features migratable` without the weather `linkable` feature, so the consumer has no direct serde_json dependency. ## 4. PR 2 — Variable-arity `migration_chain!` (proc-macro) diff --git a/docs/design/041-data-contracts-integration.md b/docs/design/041-data-contracts-integration.md index 5759d8b..7739245 100644 --- a/docs/design/041-data-contracts-integration.md +++ b/docs/design/041-data-contracts-integration.md @@ -275,8 +275,8 @@ where Notes: - **Inbound matches exactly** (`with_deserializer` takes `Result`). **Outbound needs one lossy mapping**: `with_serializer` returns `Result, SerializeError>`, so the `String` detail is dropped to `SerializeError::InvalidData`. Aligning `Linkable`'s error type with the connector layer (a shared `CodecError` instead of `String` — which also removes an alloc on embedded) is a recorded follow-up (§7); it touches core connector signatures and doesn't block this verb. - **The raw builders remain the escape hatch** for per-link options (`with_config`, QoS ext traits, topic providers/resolvers). `.linked_from`/`.linked_to` are the 80% path. -- **JSON boilerplate gets a derive:** `#[derive(Linkable)]` in `aimdb-derive` emitting `serde_json::to_vec`/`from_slice` (JSON is the default format; binary formats implement by hand, as the KNX DPT codecs rightly do). Per the D1 rule, the derive replaces a hand-written JSON impl in the same change. -- **Coupling:** the `linkable` feature gains an `aimdb-core` dependency for the ext trait (`default-features = false, features = ["alloc"]`, same wiring as `observable`). +- **JSON boilerplate gets a derive:** `#[derive(Linkable)]` in `aimdb-derive` emitting `serde_json::to_vec`/`from_slice` (JSON is the default format; binary formats implement by hand, as the KNX DPT codecs rightly do). Per the D1 rule, the derive replaces a hand-written JSON impl in the same change. After #155, this convenience lives behind `linkable-json`; base `linkable` stays format-neutral and JSON-free for Postcard/custom codecs. +- **Coupling:** the `linkable` feature gains an `aimdb-core` dependency for the ext trait (`default-features = false, features = ["alloc"]`, same wiring as `observable`). `linkable-json` adds the optional `serde_json` + derive dependencies. - **Three serialization stories, stated once** so users stop guessing: | Boundary | Mechanism | Contract | @@ -331,7 +331,8 @@ Consumed since design 039, works no_std. Stretch (recorded, not in scope): auto- ``` aimdb-data-contracts - features: linkable (+core), observable (+core), migratable, settable, + features: linkable (+core), linkable-json (+serde_json, derive), + observable (+core), migratable, settable, simulatable (+core, rand nf — no std_rng) # dev tier — never a default feature (no new crate: aimdb-simulation split considered and rejected, §3.1.1) diff --git a/examples/mqtt-connector-demo-common/Cargo.toml b/examples/mqtt-connector-demo-common/Cargo.toml index cb583cc..8980680 100644 --- a/examples/mqtt-connector-demo-common/Cargo.toml +++ b/examples/mqtt-connector-demo-common/Cargo.toml @@ -15,9 +15,9 @@ derive = ["aimdb-core/derive"] # needed: the no_std JSON paths are hand-rolled), so an embassy demo can expose # these records over a remote-access (serial) server. serde = ["dep:serde"] -# `SchemaType` + `#[derive(Linkable)]`. `no_std + alloc` -# compatible (aimdb-data-contracts's `linkable` feature only needs `alloc` — -# its `serde_json` dep is alloc-only, no `std`). Not enabled by default: the +# `SchemaType` + JSON `#[derive(Linkable)]`. `no_std + alloc` +# compatible (`linkable-json` uses serde_json's alloc-only configuration). +# Not enabled by default: the # embassy demo's hand-rolled no_std JSON path is an intentional illustration, # left untouched; only the tokio demo opts into this feature. data-contracts = ["alloc", "serde", "dep:aimdb-data-contracts"] @@ -31,9 +31,9 @@ aimdb-core = { path = "../../aimdb-core", default-features = false } # Serialization (std only) serde = { workspace = true, features = ["derive"], optional = true } serde_json = { workspace = true, optional = true } -# Optional: SchemaType + #[derive(Linkable)] (feature `data-contracts`, no_std + alloc) +# Optional: SchemaType + JSON #[derive(Linkable)] (no_std + alloc) aimdb-data-contracts = { path = "../../aimdb-data-contracts", default-features = false, optional = true, features = [ - "linkable", + "linkable-json", ] } # Optional defmt for embedded logging diff --git a/examples/tokio-mqtt-connector-demo/Cargo.toml b/examples/tokio-mqtt-connector-demo/Cargo.toml index eb5fd4b..51e150d 100644 --- a/examples/tokio-mqtt-connector-demo/Cargo.toml +++ b/examples/tokio-mqtt-connector-demo/Cargo.toml @@ -26,7 +26,8 @@ mqtt-connector-demo-common = { path = "../mqtt-connector-demo-common", features "data-contracts", ] } -# LinkableRegistrarExt / #[derive(Linkable)] verb +# Format-neutral LinkableRegistrarExt; the common crate enables linkable-json +# for its derived value types. aimdb-data-contracts = { path = "../../aimdb-data-contracts", features = [ "linkable", ] } diff --git a/tools/codegen-drift/state-mixed-codec.toml b/tools/codegen-drift/state-mixed-codec.toml new file mode 100644 index 0000000..a56c7b6 --- /dev/null +++ b/tools/codegen-drift/state-mixed-codec.toml @@ -0,0 +1,39 @@ +# Mixed JSON + Postcard fixture for `make codegen-drift`. +# +# This catches dependency and feature-unification bugs that string-only +# generator tests cannot: the generated crate must compile with both codecs. + +[project] +name = "mixed-codec-drift" +edition = "2021" + +[meta] +aimdb_version = "1.2.0" +created_at = "2026-07-11T00:00:00Z" +last_modified = "2026-07-11T00:00:00Z" + +[[records]] +name = "JsonReading" +buffer = "SpmcRing" +capacity = 16 +key_prefix = "readings.json." +key_variants = ["sensor"] +serialization = "json" + +[[records.fields]] +name = "value" +type = "f32" +description = "JSON sensor value" + +[[records]] +name = "PostcardReading" +buffer = "SpmcRing" +capacity = 16 +key_prefix = "readings.postcard." +key_variants = ["sensor"] +serialization = "postcard" + +[[records.fields]] +name = "value" +type = "f32" +description = "Postcard sensor value" diff --git a/tools/codegen-drift/state-postcard.toml b/tools/codegen-drift/state-postcard.toml new file mode 100644 index 0000000..a51bfd5 --- /dev/null +++ b/tools/codegen-drift/state-postcard.toml @@ -0,0 +1,31 @@ +# Postcard-only fixture for `make codegen-drift`. +# +# The generated common crate must compile on the host and Cortex-M without +# pulling serde_json through aimdb-data-contracts' format-neutral Linkable path. + +[project] +name = "postcard-drift" +edition = "2021" + +[meta] +aimdb_version = "1.2.0" +created_at = "2026-07-11T00:00:00Z" +last_modified = "2026-07-11T00:00:00Z" + +[[records]] +name = "PostcardReading" +buffer = "SpmcRing" +capacity = 16 +key_prefix = "readings.postcard." +key_variants = ["sensor"] +serialization = "postcard" + +[[records.fields]] +name = "value" +type = "f32" +description = "Sensor value" + +[[records.fields]] +name = "sequence" +type = "u32" +description = "Monotonic sample sequence" diff --git a/tools/scripts/codegen-drift-check.sh b/tools/scripts/codegen-drift-check.sh index 4c2fb05..63d5dd9 100755 --- a/tools/scripts/codegen-drift-check.sh +++ b/tools/scripts/codegen-drift-check.sh @@ -3,28 +3,58 @@ # # The codegen templates print API-shaped strings the compiler never checks, # so they can silently rot against the real AimDB API. This script makes the -# drift loud: it generates a common crate, a hub crate, and a flat schema -# file from tools/codegen-drift/state.toml and `cargo check`s them against -# the workspace at HEAD (design 038 §3.10 decision). +# drift loud: it generates the default common/hub/flat outputs plus Postcard- +# only and mixed-codec common crates, then compiles and exercises them against +# the workspace at HEAD (design 038 §3.10 decision, issue #155). set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" STATE="$REPO/tools/codegen-drift/state.toml" +POSTCARD_STATE="$REPO/tools/codegen-drift/state-postcard.toml" +MIXED_CODEC_STATE="$REPO/tools/codegen-drift/state-mixed-codec.toml" OUT="${CODEGEN_DRIFT_DIR:-$REPO/target/codegen-drift}" rm -rf "$OUT" mkdir -p "$OUT/.aimdb" "$OUT/drift-check-flat/src" run_generate() { + local state="$1" + shift (cd "$OUT" && cargo run --quiet --manifest-path "$REPO/Cargo.toml" \ - -p aimdb-cli -- generate --state "$STATE" \ + -p aimdb-cli -- generate --state "$state" \ --mermaid "$OUT/.aimdb/architecture.mermaid" "$@") } echo "── Generating common crate, hub crate, and flat schema from fixture" -run_generate --common-crate -run_generate --hub -run_generate --rust "$OUT/drift-check-flat/src/generated_schema.rs" +run_generate "$STATE" --common-crate +run_generate "$STATE" --hub +run_generate "$STATE" --rust "$OUT/drift-check-flat/src/generated_schema.rs" + +echo "── Generating Postcard-only and mixed-codec common crates" +run_generate "$POSTCARD_STATE" --common-crate +run_generate "$MIXED_CODEC_STATE" --common-crate + +# Exercise the generated Postcard codec as behavior, not just compilable tokens. +mkdir -p "$OUT/postcard-drift-common/tests" +cat > "$OUT/postcard-drift-common/tests/postcard_roundtrip.rs" <<'EOF' +use aimdb_data_contracts::Linkable; +use postcard_drift_common::PostcardReadingValue; + +#[test] +fn generated_postcard_linkable_roundtrips() { + let expected = PostcardReadingValue { + value: 23.75, + sequence: 42, + }; + + let bytes = expected.to_bytes().expect("serialize with postcard"); + assert!(!bytes.is_empty()); + + let actual = PostcardReadingValue::from_bytes(&bytes).expect("deserialize with postcard"); + assert_eq!(actual.value.to_bits(), expected.value.to_bits()); + assert_eq!(actual.sequence, expected.sequence); +} +EOF # Wrapper crate around the flat-mode output. The generated Linkable impls # spell types as `alloc::…` (shared with the no_std common-crate emitters), @@ -62,16 +92,55 @@ EOF # dependency at the local workspace instead so we compile against HEAD. sed -i -E "s|^(aimdb-[a-z0-9-]+) = \\{ version = \"[^\"]+\"|\\1 = { path = \"$REPO/\\1\"|" \ "$OUT/drift-check-common/Cargo.toml" \ - "$OUT/drift-check-hub/Cargo.toml" + "$OUT/drift-check-hub/Cargo.toml" \ + "$OUT/postcard-drift-common/Cargo.toml" \ + "$OUT/mixed-codec-drift-common/Cargo.toml" -# Stitch the three crates into a throwaway workspace and compile. +# Stitch the generated crates into a throwaway workspace and compile. cat > "$OUT/Cargo.toml" <<'EOF' [workspace] -members = ["drift-check-common", "drift-check-hub", "drift-check-flat"] +members = [ + "drift-check-common", + "drift-check-hub", + "drift-check-flat", + "postcard-drift-common", + "mixed-codec-drift-common", +] resolver = "2" EOF echo "── Compiling generated output against the workspace" cargo check --manifest-path "$OUT/Cargo.toml" --workspace -echo "✓ codegen output compiles against the workspace" +echo "── Running generated Postcard Linkable roundtrip" +cargo test --manifest-path "$OUT/Cargo.toml" -p postcard-drift-common + +echo "── Cross-compiling generated Postcard crate for Cortex-M" +cargo check --manifest-path "$OUT/Cargo.toml" -p postcard-drift-common \ + --target thumbv7em-none-eabihf \ + --target-dir "$OUT/target-thumb" \ + --no-default-features --features alloc + +assert_not_in_normal_graph() { + local package="$1" + local dependency="$2" + local output + + if ! output=$(cargo tree --manifest-path "$OUT/Cargo.toml" \ + -p "$package" --edges normal --prefix none 2>&1); then + echo "error: failed to inspect the normal dependency graph for $package" + printf '%s\n' "$output" + exit 1 + fi + + if printf '%s\n' "$output" | grep -Eq "^${dependency} v"; then + echo "error: $package unexpectedly pulls $dependency" + printf '%s\n' "$output" + exit 1 + fi +} + +echo "── Proving the generated Postcard graph is JSON-free" +assert_not_in_normal_graph postcard-drift-common serde_json + +echo "✓ generated default, Postcard, and mixed-codec output passed drift checks"