Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 41 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,15 @@ impl Todo {
Create events at a specific version for [upcasting](#event-upcasting--versioning):

```rust
type InitV1 = (String, String);
type InitV2 = (String, String, u8);

fn upcast_init_v1_v2((id, task): InitV1) -> InitV2 {
(id, task, 0)
}

#[sourced(entity, upcasters(
("Initialized", 1 => 2, upcast_init_v1_v2),
("Initialized", 1 => 2, InitV1 => InitV2, upcast_init_v1_v2),
))]
impl TodoV2 {
#[event("Initialized", version = 2)]
Expand Down Expand Up @@ -381,11 +388,18 @@ aggregate!(Todo, entity {
With [upcasters](#event-upcasting--versioning) for event schema evolution:

```rust
type InitV1 = (String, String);
type InitV2 = (String, String, u8);

fn upcast_initialized_v1_v2((id, task): InitV1) -> InitV2 {
(id, task, 0)
}

aggregate!(Todo, entity {
"Initialized"(id, task, priority) => initialize,
"Completed"() => complete(),
} upcasters [
("Initialized", 1 => 2, upcast_initialized_v1_v2),
("Initialized", 1 => 2, InitV1 => InitV2, upcast_initialized_v1_v2),
]);
```

Expand Down Expand Up @@ -1251,17 +1265,19 @@ let todo = repo.get("todo-1")?.unwrap();

## Event Upcasting / Versioning

Event schemas evolve over time. When you add a field to an event (e.g., `priority` to `Initialized`), old serialized events in storage can't deserialize into the new type — especially with bitcode's rigid binary format. **Upcasters** solve this: pure functions that transform old event payloads into the current format at read time, without modifying stored data.
Event schemas evolve over time. When you add a field to an event (e.g., `priority` to `Initialized`), old serialized events in storage can't deserialize into the new type. **Upcasters** solve this: typed functions that transform old event payload shapes into the current format at read time, without modifying stored data.

### Defining an Upcaster

An upcaster is a plain function that converts a payload from one version to the next:
An upcaster is a plain function that converts a typed payload from one version to the next. The crate handles payload decoding and encoding:

```rust
type InitV1 = (String, String);
type InitV2 = (String, String, u8);

/// Upcasts Initialized v1 (id, task) → v2 (id, task, priority)
fn upcast_init_v1_v2(payload: &[u8]) -> Vec<u8> {
let (id, task): (String, String) = bitcode::deserialize(payload).unwrap();
bitcode::serialize(&(id, task, 0u8)).unwrap() // default priority = 0
fn upcast_init_v1_v2((id, task): InitV1) -> InitV2 {
(id, task, 0)
}
```

Expand All @@ -1279,7 +1295,7 @@ struct Todo {
}

#[sourced(entity, upcasters(
("Initialized", 1 => 2, upcast_init_v1_v2),
("Initialized", 1 => 2, InitV1 => InitV2, upcast_init_v1_v2),
))]
impl Todo {
#[event("Initialized", version = 2)]
Expand Down Expand Up @@ -1308,7 +1324,7 @@ aggregate!(Todo, entity {
"Initialized"(id, task, priority) => initialize,
"Completed"() => complete(),
} upcasters [
("Initialized", 1 => 2, upcast_init_v1_v2),
("Initialized", 1 => 2, InitV1 => InitV2, upcast_init_v1_v2),
]);
```

Expand All @@ -1319,19 +1335,21 @@ Old events stored as `(id, task)` at v1 get transparently upcasted to `(id, task
Upcasters chain automatically. Each transforms one version to the next (v1->v2->v3):

```rust
fn upcast_init_v1_v2(payload: &[u8]) -> Vec<u8> {
let (id, task): (String, String) = bitcode::deserialize(payload).unwrap();
bitcode::serialize(&(id, task, 0u8)).unwrap()
type InitV1 = (String, String);
type InitV2 = (String, String, u8);
type InitV3 = (String, String, u8, String);

fn upcast_init_v1_v2((id, task): InitV1) -> InitV2 {
(id, task, 0)
}

fn upcast_init_v2_v3(payload: &[u8]) -> Vec<u8> {
let (id, task, priority): (String, String, u8) = bitcode::deserialize(payload).unwrap();
bitcode::serialize(&(id, task, priority, String::new())).unwrap() // add due_date
fn upcast_init_v2_v3((id, task, priority): InitV2) -> InitV3 {
(id, task, priority, String::new())
}

#[sourced(entity, upcasters(
("Initialized", 1 => 2, upcast_init_v1_v2),
("Initialized", 2 => 3, upcast_init_v2_v3),
("Initialized", 1 => 2, InitV1 => InitV2, upcast_init_v1_v2),
("Initialized", 2 => 3, InitV2 => InitV3, upcast_init_v2_v3),
))]
impl Todo {
#[event("Initialized", version = 3)]
Expand All @@ -1351,26 +1369,16 @@ A v1 event automatically chains through v1->v2->v3. A v2 event only goes through
- **No stored data modified**: Upcasters are read-time transformations. The event store is never touched.
- **Zero overhead when unused**: If an aggregate has no upcasters, `hydrate()` takes the fast path with no extra allocation.

### The `EventUpcaster` Struct
### Direct Upcasting

Under the hood, each upcaster is a plain struct with a function pointer — no traits, no boxing:
You can also use `upcast_events()` directly with an aggregate's registered upcasters for custom hydration logic:

```rust
pub struct EventUpcaster {
pub event_type: &'static str,
pub from_version: u64,
pub to_version: u64,
pub transform: fn(payload: &[u8]) -> Vec<u8>,
}
```

You can also use `upcast_events()` directly for custom hydration logic:
use sourced_rust::{upcast_events, Aggregate, EventRecord, UpcastError};

```rust
use sourced_rust::{upcast_events, EventUpcaster};

let upcasters: &[EventUpcaster] = &[/* ... */];
let upcasted = upcast_events(events, upcasters);
fn upcast_for_replay(events: Vec<EventRecord>) -> Result<Vec<EventRecord>, UpcastError> {
upcast_events(events, Todo::upcasters())
}
```

## Project Structure
Expand Down
157 changes: 102 additions & 55 deletions sourced_rust_macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,85 @@ fn generate_enqueue_call(
}
}

fn upcaster_wrapper_prefix(owner: &Ident) -> String {
owner
.to_string()
.trim_start_matches("r#")
.to_ascii_lowercase()
}

fn generate_upcaster_tokens(
owner: &Ident,
upcasters: &[UpcasterDef],
) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) {
if upcasters.is_empty() {
return (quote! {}, quote! {});
}

let prefix = upcaster_wrapper_prefix(owner);
let wrapper_names: Vec<_> = upcasters
.iter()
.enumerate()
.map(|(idx, _)| format_ident!("__sourced_upcast_{}_{}", prefix, idx))
.collect();

let wrapper_defs = upcasters
.iter()
.zip(wrapper_names.iter())
.map(|(u, wrapper)| {
let source_type = &u.source_type;
let target_type = &u.target_type;
let to_version = &u.to_version;
let transform_fn = &u.transform_fn;
quote! {
fn #wrapper(
event: &sourced_rust::EventRecord,
) -> Result<Vec<u8>, sourced_rust::UpcastError> {
sourced_rust::upcast_payload::<#source_type, #target_type>(
event,
#to_version,
#transform_fn,
)
}
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let upcaster_entries = upcasters
.iter()
.zip(wrapper_names.iter())
.map(|(u, wrapper)| {
let event_name = &u.event_name;
let from_version = &u.from_version;
let to_version = &u.to_version;
quote! {
sourced_rust::EventUpcaster {
event_type: #event_name,
from_version: #from_version,
to_version: #to_version,
transform: #owner::#wrapper,
}
}
});

let upcasters_method = quote! {
fn upcasters() -> &'static [sourced_rust::EventUpcaster] {
static UPCASTERS: &[sourced_rust::EventUpcaster] = &[
#(#upcaster_entries),*
];
UPCASTERS
}
};

(
quote! {
impl #owner {
#(#wrapper_defs)*
}
},
upcasters_method,
)
}

// ============================================================================
// #[enqueue] attribute macro
// ============================================================================
Expand Down Expand Up @@ -543,35 +622,12 @@ pub fn aggregate(input: TokenStream) -> TokenStream {
}
});

// Generate upcasters() method if upcasters are defined
let upcasters_method = if input.upcasters.is_empty() {
quote! {}
} else {
let upcaster_entries = input.upcasters.iter().map(|u| {
let event_name = &u.event_name;
let from_version = &u.from_version;
let to_version = &u.to_version;
let transform_fn = &u.transform_fn;
quote! {
sourced_rust::EventUpcaster {
event_type: #event_name,
from_version: #from_version,
to_version: #to_version,
transform: #transform_fn,
}
}
});
quote! {
fn upcasters() -> &'static [sourced_rust::EventUpcaster] {
static UPCASTERS: &[sourced_rust::EventUpcaster] = &[
#(#upcaster_entries),*
];
UPCASTERS
}
}
};
let (upcaster_wrappers, upcasters_method) =
generate_upcaster_tokens(agg_name, &input.upcasters);

let expanded = quote! {
#upcaster_wrappers

impl sourced_rust::Aggregate for #agg_name {
type ReplayError = String;

Expand Down Expand Up @@ -605,6 +661,8 @@ struct UpcasterDef {
event_name: LitStr,
from_version: syn::LitInt,
to_version: syn::LitInt,
source_type: syn::Type,
target_type: syn::Type,
transform_fn: syn::Path,
}

Expand Down Expand Up @@ -681,7 +739,7 @@ impl Parse for AggregateInput {
syn::bracketed!(upcaster_content in input);

while !upcaster_content.is_empty() {
// Parse: ("EventName", from => to, transform_fn)
// Parse: ("EventName", from => to, SourceType => TargetType, transform_fn)
let inner;
syn::parenthesized!(inner in upcaster_content);

Expand All @@ -691,12 +749,18 @@ impl Parse for AggregateInput {
inner.parse::<Token![=>]>()?;
let to_version: syn::LitInt = inner.parse()?;
inner.parse::<Token![,]>()?;
let source_type: syn::Type = inner.parse()?;
inner.parse::<Token![=>]>()?;
let target_type: syn::Type = inner.parse()?;
inner.parse::<Token![,]>()?;
let transform_fn: syn::Path = inner.parse()?;

upcasters.push(UpcasterDef {
event_name,
from_version,
to_version,
source_type,
target_type,
transform_fn,
});

Expand Down Expand Up @@ -764,11 +828,17 @@ fn parse_sourced_args(input: ParseStream) -> syn::Result<SourcedArgs> {
inner.parse::<Token![=>]>()?;
let to_ver: syn::LitInt = inner.parse()?;
inner.parse::<Token![,]>()?;
let source_type: syn::Type = inner.parse()?;
inner.parse::<Token![=>]>()?;
let target_type: syn::Type = inner.parse()?;
inner.parse::<Token![,]>()?;
let transform: syn::Path = inner.parse()?;
upcasters.push(UpcasterDef {
event_name: ev_name,
from_version: from_ver,
to_version: to_ver,
source_type,
target_type,
transform_fn: transform,
});
if upcaster_content.peek(Token![,]) {
Expand Down Expand Up @@ -873,7 +943,7 @@ struct EventMethodInfo {
/// Options:
/// - `#[sourced(entity)]` - entity field name
/// - `#[sourced(entity, events = "CustomName")]` - custom enum name
/// - `#[sourced(entity, upcasters(("EventName", 1 => 2, upcast_fn)))]` - upcasters
/// - `#[sourced(entity, upcasters(("EventName", 1 => 2, OldPayload => NewPayload, upcast_fn)))]` - upcasters
#[proc_macro_attribute]
pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream {
let args = parse_macro_input!(attr with parse_sourced_args);
Expand Down Expand Up @@ -1075,32 +1145,8 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream {
}
});

let upcasters_method = if args.upcasters.is_empty() {
quote! {}
} else {
let upcaster_entries = args.upcasters.iter().map(|u| {
let ev_name = &u.event_name;
let from_v = &u.from_version;
let to_v = &u.to_version;
let transform = &u.transform_fn;
quote! {
sourced_rust::EventUpcaster {
event_type: #ev_name,
from_version: #from_v,
to_version: #to_v,
transform: #transform,
}
}
});
quote! {
fn upcasters() -> &'static [sourced_rust::EventUpcaster] {
static UPCASTERS: &[sourced_rust::EventUpcaster] = &[
#(#upcaster_entries),*
];
UPCASTERS
}
}
};
let (upcaster_wrappers, upcasters_method) =
generate_upcaster_tokens(&struct_name, &args.upcasters);

let aggregate_impl = quote! {
impl sourced_rust::Aggregate for #struct_name {
Expand Down Expand Up @@ -1134,6 +1180,7 @@ pub fn sourced(attr: TokenStream, item: TokenStream) -> TokenStream {
#enum_def
#event_name_impl
#try_from_impl
#upcaster_wrappers
#aggregate_impl
};

Expand Down
2 changes: 1 addition & 1 deletion src/entity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ pub use event_record::{
BITCODE_PAYLOAD_CODEC_VERSION,
};
pub use local_event::LocalEvent;
pub use upcaster::{upcast_events, EventUpcaster, UpcastError};
pub use upcaster::{upcast_events, upcast_payload, EventUpcaster, UpcastError};
Loading
Loading