The transform API (TransformBuilder, StatefulTransformBuilder, TransformPipeline) is partially
usable in no_std environments but the multi-input join (JoinBuilder, JoinStateBuilder,
JoinPipeline, run_join_transform) is entirely gated behind #[cfg(feature = "std")]. Even the
single-input path has implicit std dependencies through its task runner.
Embedded firmware (e.g., RP2040, STM32) running AimDB via the Embassy adapter currently cannot
define reactive derivations at all — they must hand-roll subscription loops themselves, losing the
clean declarative model that std users enjoy.
This issue tracks making the full transform API available under no_std + alloc.
Current State
What is std-only and why
| Symbol |
File |
std Dependency |
JoinBuilder<O, R> |
transform.rs:257 |
tokio::sync::mpsc::UnboundedSender |
JoinInputFactory<R> (type alias) |
transform.rs:264 |
tokio::sync::mpsc::UnboundedSender |
JoinStateBuilder<O, S, R> |
transform.rs:357 |
Same — propagated from JoinBuilder |
JoinPipeline<O, R> |
transform.rs:401 |
Same — propagated from JoinBuilder |
run_join_transform(...) |
transform.rs:517 |
tokio::sync::mpsc::unbounded_channel |
TransformDescriptor storage in TypedRecord |
typed_record.rs:27-52 |
std::sync::Arc, std::boxed::Box |
The single-input path (TransformBuilder → TransformPipeline → run_single_transform) itself
contains no direct std imports, but it lives inside a crate that conditionally uses
std::boxed::Box vs alloc::boxed::Box, so it compiles fine under no_std + alloc. The task
runner (run_single_transform) is already clean.
The only real blocker is the join path, which uses tokio::sync::mpsc as a fan-in channel.
What already works in no_std
TransformBuilder / StatefulTransformBuilder / TransformPipeline — struct layout is clean
run_single_transform — pure async loop, no std imports
TransformDescriptor — works with alloc (uses alloc::boxed::Box, alloc::vec::Vec)
Proposed Solution
1 — Single-input transform: enable unconditionally (easy, low risk)
Remove the #[cfg(feature = "std")] guards on the TransformDescriptor fields in
typed_record.rs that prevent the transform descriptor from being stored on no_std targets. The
descriptor itself only needs alloc. The std guard was inherited transitively from JoinBuilder
and is not required here.
Change: In typed_record.rs, gate the TransformDescriptor field on alloc instead of std:
// Before
#[cfg(feature = "std")]
pub(crate) transform: Option<TransformDescriptor<T, R>>,
// After
#[cfg(feature = "alloc")]
pub(crate) transform: Option<TransformDescriptor<T, R>>,
2 — Join transform: replace tokio::sync::mpsc with embassy-sync
The join's fan-in channel is currently tokio::sync::mpsc::UnboundedSender. On Embassy targets
this can be replaced with embassy_sync::channel::Channel<M, JoinTrigger, CAP>.
The key design tension is capacity: tokio's unbounded channel has no compile-time size;
Embassy's Channel is const-generic and statically allocated. We have two options:
Option A — Const-generic capacity parameter on JoinBuilder (recommended)
Add a const CAP: usize generic to JoinBuilder (and propagate through JoinStateBuilder,
JoinPipeline, run_join_transform). Provide a type alias with a sensible default:
// no_std path
pub struct JoinBuilder<O, R, const CAP: usize = 8> { ... }
// The channel backing the fan-in queue
static JOIN_CHANNEL: Channel<CriticalSectionRawMutex, JoinTrigger, CAP> = Channel::new();
Note: Embassy channels require a 'static reference. The standard pattern is to declare the
channel as a module-level static in the user's firmware. JoinBuilder will need to accept a
&'static Channel<M, JoinTrigger, CAP> from the caller, rather than creating it internally.
Option B — heapless::spsc::Queue as an alloc-free ring
Simpler from a library perspective, but caller must decide queue capacity up-front and SPSC does
not support the multi-forwarder pattern without additional synchronization.
Recommended approach: split the join implementation by feature flag
aimdb-core/src/transform/
mod.rs -- re-exports, shared types (JoinTrigger, TransformDescriptor)
single.rs -- TransformBuilder, StatefulTransformBuilder, TransformPipeline
join_std.rs -- JoinBuilder (std path, tokio::mpsc, unchanged)
join_nostd.rs -- JoinBuilder (no_std path, embassy-sync Channel)
Use a feature gate to select the right module:
#[cfg(feature = "std")]
pub use join_std::{JoinBuilder, JoinPipeline, JoinStateBuilder};
#[cfg(all(not(feature = "std"), feature = "alloc"))]
pub use join_nostd::{JoinBuilder, JoinPipeline, JoinStateBuilder};
3 — Embassy adapter: expose transform_join via EmbassyRecordRegistrarExt
The macro-generated extension trait EmbassyRecordRegistrarExt currently only wires up
source, tap, and with_transform. Add with_transform_join to the Embassy arm once step 2
is complete.
API Impact
Single-input (step 1 only — backwards compatible)
No user-visible API changes. Existing std code is unchanged. Embassy firmware gains the ability
to call .transform() on a record without code changes.
Join (step 2 — new API surface on embedded)
// Firmware (no_std) — user provides a static channel for the fan-in queue
static JOIN_CH: Channel<CriticalSectionRawMutex, JoinTrigger, 16> = Channel::new();
registrar
.register::<HeatIndex>("sensor::HeatIndex", buffer_sized::<4, 2>(BufferType::SpmcRing))
.transform_join(&JOIN_CH)
.input::<Temperature>("sensor::Temperature")
.input::<Humidity>("sensor::Humidity")
.with_state(HeatIndexState::default())
.on_trigger(|trigger, state, producer| async move {
match trigger.index() {
0 => state.temperature = trigger.as_input::<Temperature>().copied(),
1 => state.humidity = trigger.as_input::<Humidity>().copied(),
_ => {}
}
if let (Some(t), Some(h)) = (state.temperature, state.humidity) {
let _ = producer.produce(heat_index(t, h)).await;
}
});
The std API stays identical (caller does not pass a channel; one is created internally).
Implementation Checklist
Risk & Constraints
| Risk |
Mitigation |
'static lifetime requirement on Embassy channels |
Caller provides the channel as &'static; document pattern clearly |
| Const-generic capacity leaks into public API |
Provide a DefaultJoinBuilder<O, R> type alias with CAP = 16 |
Feature flag complexity growing in aimdb-core |
Keep std/alloc/no_std split consistent with existing typed_record.rs patterns |
Breaking change if JoinBuilder gains const CAP |
Gate new generic behind no_std; std path remains unchanged |
Acceptance Criteria
cargo check --target thumbv7em-none-eabihf --features embassy-runtime passes with a crate that uses .transform() (single-input map).
cargo check --target thumbv7em-none-eabihf --features embassy-runtime passes with a crate that uses .transform_join() (multi-input join).
- All existing
std tests continue to pass (make test).
- A new Embassy adapter integration test demonstrates both single and multi-input transforms on a mock embedded database.
- No new
std-only imports are introduced in aimdb-core/src/transform/single.rs.
Related
aimdb-embassy-adapter/src/buffer.rs — PubSubChannel, Watch, Channel already in use; same primitives can back the join fan-in
aimdb-core/src/typed_record.rs — existing #[cfg(feature = "std")] / #[cfg(not(feature = "std"))] pattern to follow
aimdb-core/src/transform.rs — file to be refactored
tools/aimdb-mcp/ — MCP server graph introspection should auto-discover Embassy transforms once they are registered in the dependency graph (no extra work expected)
The transform API (
TransformBuilder,StatefulTransformBuilder,TransformPipeline) is partiallyusable in
no_stdenvironments but the multi-input join (JoinBuilder,JoinStateBuilder,JoinPipeline,run_join_transform) is entirely gated behind#[cfg(feature = "std")]. Even thesingle-input path has implicit
stddependencies through its task runner.Embedded firmware (e.g., RP2040, STM32) running AimDB via the Embassy adapter currently cannot
define reactive derivations at all — they must hand-roll subscription loops themselves, losing the
clean declarative model that
stdusers enjoy.This issue tracks making the full transform API available under
no_std + alloc.Current State
What is
std-only and whystdDependencyJoinBuilder<O, R>transform.rs:257tokio::sync::mpsc::UnboundedSenderJoinInputFactory<R>(type alias)transform.rs:264tokio::sync::mpsc::UnboundedSenderJoinStateBuilder<O, S, R>transform.rs:357JoinBuilderJoinPipeline<O, R>transform.rs:401JoinBuilderrun_join_transform(...)transform.rs:517tokio::sync::mpsc::unbounded_channelTransformDescriptorstorage inTypedRecordtyped_record.rs:27-52std::sync::Arc,std::boxed::BoxThe single-input path (
TransformBuilder→TransformPipeline→run_single_transform) itselfcontains no direct
stdimports, but it lives inside a crate that conditionally usesstd::boxed::Boxvsalloc::boxed::Box, so it compiles fine underno_std + alloc. The taskrunner (
run_single_transform) is already clean.The only real blocker is the join path, which uses
tokio::sync::mpscas a fan-in channel.What already works in
no_stdTransformBuilder/StatefulTransformBuilder/TransformPipeline— struct layout is cleanrun_single_transform— pure async loop, nostdimportsTransformDescriptor— works withalloc(usesalloc::boxed::Box,alloc::vec::Vec)Proposed Solution
1 — Single-input transform: enable unconditionally (easy, low risk)
Remove the
#[cfg(feature = "std")]guards on theTransformDescriptorfields intyped_record.rsthat prevent the transform descriptor from being stored onno_stdtargets. Thedescriptor itself only needs
alloc. Thestdguard was inherited transitively fromJoinBuilderand is not required here.
Change: In
typed_record.rs, gate theTransformDescriptorfield onallocinstead ofstd:2 — Join transform: replace
tokio::sync::mpscwithembassy-syncThe join's fan-in channel is currently
tokio::sync::mpsc::UnboundedSender. On Embassy targetsthis can be replaced with
embassy_sync::channel::Channel<M, JoinTrigger, CAP>.The key design tension is capacity:
tokio's unbounded channel has no compile-time size;Embassy's
Channelis const-generic and statically allocated. We have two options:Option A — Const-generic capacity parameter on
JoinBuilder(recommended)Add a
const CAP: usizegeneric toJoinBuilder(and propagate throughJoinStateBuilder,JoinPipeline,run_join_transform). Provide a type alias with a sensible default:Option B —
heapless::spsc::Queueas an alloc-free ringSimpler from a library perspective, but caller must decide queue capacity up-front and SPSC does
not support the multi-forwarder pattern without additional synchronization.
Recommended approach: split the join implementation by feature flag
Use a feature gate to select the right module:
3 — Embassy adapter: expose
transform_joinviaEmbassyRecordRegistrarExtThe macro-generated extension trait
EmbassyRecordRegistrarExtcurrently only wires upsource,tap, andwith_transform. Addwith_transform_jointo the Embassy arm once step 2is complete.
API Impact
Single-input (step 1 only — backwards compatible)
No user-visible API changes. Existing
stdcode is unchanged. Embassy firmware gains the abilityto call
.transform()on a record without code changes.Join (step 2 — new API surface on embedded)
The
stdAPI stays identical (caller does not pass a channel; one is created internally).Implementation Checklist
stdgate fromTransformDescriptorintyped_record.rs; gate onallocinsteadstdgate fromTypedRecord::set_transform()intyped_record.rs--target thumbv7em-none-eabihf --features embassy-runtimeaimdb-embassy-adapter/tests/for single-input map transformtransform.rsintotransform/mod.rs,transform/single.rs,transform/join_std.rstransform/join_nostd.rsusingembassy_sync::channel::ChannelJoinTriggeras a shared no_std-safe type (already clean — nostdimports)transform_joinintoEmbassyRecordRegistrarExtmacroCargo.tomlif a newembassy-syncimport is needed insideaimdb-coreaimdb-usage-guide.mdto document theno_stdjoin API difference (static channel requirement)no_stdsection totransform.rsmodule-level doc commentRisk & Constraints
'staticlifetime requirement on Embassy channels&'static; document pattern clearlyDefaultJoinBuilder<O, R>type alias withCAP = 16aimdb-corestd/alloc/no_stdsplit consistent with existingtyped_record.rspatternsJoinBuildergainsconst CAPno_std;stdpath remains unchangedAcceptance Criteria
cargo check --target thumbv7em-none-eabihf --features embassy-runtimepasses with a crate that uses.transform()(single-input map).cargo check --target thumbv7em-none-eabihf --features embassy-runtimepasses with a crate that uses.transform_join()(multi-input join).stdtests continue to pass (make test).std-only imports are introduced inaimdb-core/src/transform/single.rs.Related
aimdb-embassy-adapter/src/buffer.rs—PubSubChannel,Watch,Channelalready in use; same primitives can back the join fan-inaimdb-core/src/typed_record.rs— existing#[cfg(feature = "std")]/#[cfg(not(feature = "std"))]pattern to followaimdb-core/src/transform.rs— file to be refactoredtools/aimdb-mcp/— MCP server graph introspection should auto-discover Embassy transforms once they are registered in the dependency graph (no extra work expected)