Skip to content

Commit ab05134

Browse files
authored
#133 fix(aggregate_root): emit Created event from save() for streams entities (#134)
`crates/entity-derive-impl/src/entity/sql/postgres/save.rs::save_method` already wrapped the INSERT in a transaction (#118 era), but for entities with both `aggregate_root` and `streams` enabled it never spliced `pg_notify`. Subscribers missed every new aggregate-root insert silently, even though the row write itself was atomic. Splice `self.notify_created()` into the existing transaction body, after the INSERT but before the commit. The notify executes against `&mut *tx`, the same handle the INSERT uses, so Postgres only broadcasts `Created` on commit and discards it on rollback — the same guarantee already in place for `create_method` (#125). When `streams` is off, `notify_created()` returns an empty TokenStream, so non-streams aggregate roots stay one round-trip with no regression. Tests (3 new lib tests in `save::tests`): - `save_emits_pg_notify_when_streams_enabled`: combined attribute set produces a save body that contains `pg_notify` and executes it on `&mut *tx`. - `save_omits_pg_notify_when_streams_disabled`: aggregate_root without streams does NOT emit `pg_notify` (perf regression guard). - `save_is_empty_for_non_aggregate_root`: untouched control case — `save_method` returns empty when aggregate_root is off. Bump: - entity-derive-impl: 0.6.3 -> 0.6.4 - entity-derive: 0.8.4 -> 0.8.5 `entity-core` is unchanged. Closes #133
1 parent 2e5db58 commit ab05134

3 files changed

Lines changed: 87 additions & 2 deletions

File tree

crates/entity-derive-impl/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[package]
55
name = "entity-derive-impl"
6-
version = "0.6.3"
6+
version = "0.6.4"
77
edition.workspace = true
88
rust-version.workspace = true
99
authors.workspace = true

crates/entity-derive-impl/src/entity/sql/postgres/save.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ impl Context<'_> {
7575

7676
let span = instrument(&entity_name.to_string(), "save");
7777

78+
// `notify_created` returns an empty TokenStream when streams are off,
79+
// so the same generator covers both streams-enabled and plain
80+
// aggregate roots. When streams ARE on, the splice runs against the
81+
// same transaction (`&mut *tx`) that wraps the INSERT, so Postgres
82+
// only broadcasts the `Created` event on commit and discards it on
83+
// rollback — atomic with the row write. See #133.
84+
let notify = self.notify_created();
85+
7886
quote! {
7987
#span
8088
async fn save(&self, new: #new_name) -> Result<#entity_name, #error_type> {
@@ -89,9 +97,86 @@ impl Context<'_> {
8997
.fetch_one(&mut *tx).await?;
9098
entity = #entity_name::from(row);
9199

100+
#notify
101+
92102
tx.commit().await?;
93103
Ok(entity)
94104
}
95105
}
96106
}
97107
}
108+
109+
#[cfg(test)]
110+
mod tests {
111+
use syn::parse_quote;
112+
113+
use super::*;
114+
use crate::entity::parse::EntityDef;
115+
116+
fn ctx_for(input: syn::DeriveInput) -> Context<'static> {
117+
// Leak the parsed entity so the borrowed-against `Context` outlives
118+
// the test scope. Acceptable in test code; saves shuffling lifetimes.
119+
let entity: &'static EntityDef = Box::leak(Box::new(
120+
EntityDef::from_derive_input(&input).expect("parse ok")
121+
));
122+
Context::new(entity)
123+
}
124+
125+
#[test]
126+
fn save_emits_pg_notify_when_streams_enabled() {
127+
let ctx = ctx_for(parse_quote! {
128+
#[entity(table = "users", aggregate_root, streams)]
129+
pub struct User {
130+
#[id]
131+
pub id: ::uuid::Uuid,
132+
#[field(create, update, response)]
133+
pub email: String
134+
}
135+
});
136+
let tokens = ctx.save_method().to_string();
137+
assert!(
138+
tokens.contains("pg_notify"),
139+
"streams + aggregate_root must splice pg_notify into save(), got: {tokens}"
140+
);
141+
// The notify must run on the transaction handle so it commits
142+
// atomically with the INSERT, not after.
143+
assert!(
144+
tokens.contains("& mut * tx"),
145+
"pg_notify must execute on `&mut *tx`, got: {tokens}"
146+
);
147+
}
148+
149+
#[test]
150+
fn save_omits_pg_notify_when_streams_disabled() {
151+
let ctx = ctx_for(parse_quote! {
152+
#[entity(table = "users", aggregate_root)]
153+
pub struct User {
154+
#[id]
155+
pub id: ::uuid::Uuid,
156+
#[field(create, update, response)]
157+
pub email: String
158+
}
159+
});
160+
let tokens = ctx.save_method().to_string();
161+
assert!(
162+
!tokens.contains("pg_notify"),
163+
"non-streams aggregate root must NOT emit pg_notify (perf regression guard), got: {tokens}"
164+
);
165+
}
166+
167+
#[test]
168+
fn save_is_empty_for_non_aggregate_root() {
169+
let ctx = ctx_for(parse_quote! {
170+
#[entity(table = "users")]
171+
pub struct User {
172+
#[id]
173+
pub id: ::uuid::Uuid
174+
}
175+
});
176+
let tokens = ctx.save_method();
177+
assert!(
178+
tokens.is_empty(),
179+
"save() must not be generated unless aggregate_root is on, got: {tokens}"
180+
);
181+
}
182+
}

crates/entity-derive/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[package]
55
name = "entity-derive"
6-
version = "0.8.4"
6+
version = "0.8.5"
77
edition.workspace = true
88
rust-version.workspace = true
99
authors.workspace = true

0 commit comments

Comments
 (0)