es-entity 0.11.9

Event Sourcing Entity Framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
mod entities;
mod helpers;

use entities::customer::*;
use es_entity::*;
use sqlx::PgPool;

#[derive(EsRepo, Debug)]
#[es_repo(entity = "Customer", forgettable, columns(email(ty = "String")))]
pub struct Customers {
    pool: PgPool,
}

impl Customers {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[tokio::test]
async fn create_and_load_with_forgettable_fields() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool);

    let new_customer = NewCustomer::builder()
        .id(CustomerId::new())
        .name("Alice Smith")
        .email("alice@example.com")
        .build()
        .unwrap();

    let customer = customers.create(new_customer).await?;
    assert_eq!(customer.name, "Alice Smith");
    assert_eq!(customer.email, "alice@example.com");

    // Load the customer and verify data is intact
    let loaded = customers.find_by_id(customer.id).await?;
    assert_eq!(loaded.name, "Alice Smith");
    assert_eq!(loaded.email, "alice@example.com");

    Ok(())
}

#[tokio::test]
async fn forget_removes_forgettable_data() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool);

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Bob Jones")
        .email("bob@example.com")
        .build()
        .unwrap();

    let mut customer = customers.create(new_customer).await?;
    assert_eq!(customer.name, "Bob Jones");

    // Update the name (adds another event with a forgettable field)
    let _ = customer.update_name("Robert Jones");
    customers.update(&mut customer).await?;

    // Verify before forget
    let loaded = customers.find_by_id(id).await?;
    assert_eq!(loaded.name, "Robert Jones");
    assert_eq!(loaded.email, "bob@example.com");

    // Forget the customer's personal data - consumes and returns the rebuilt,
    // forgotten entity.
    let loaded = customers.find_by_id(id).await?;
    let loaded = customers.forget(loaded).await?;

    assert_eq!(loaded.name, "[forgotten]");
    // Non-forgettable field should remain intact
    assert_eq!(loaded.email, "bob@example.com");

    Ok(())
}

#[tokio::test]
async fn forget_preserves_non_forgettable_events() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool);

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Charlie")
        .email("charlie@example.com")
        .build()
        .unwrap();

    let mut customer = customers.create(new_customer).await?;

    // Update email (non-forgettable field)
    let _ = customer.update_email("charlie_new@example.com");
    customers.update(&mut customer).await?;

    // Forget and verify - forget consumes and returns the rebuilt, forgotten
    // entity.
    let loaded = customers.find_by_id(id).await?;
    let loaded = customers.forget(loaded).await?;

    assert_eq!(loaded.name, "[forgotten]");
    assert_eq!(loaded.email, "charlie_new@example.com");

    Ok(())
}

#[tokio::test]
async fn staged_erasure_event_is_persisted_by_forget() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool.clone());

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Marker Test")
        .email("marker@example.com")
        .build()
        .unwrap();

    let mut customer = customers.create(new_customer).await?;

    // Convention: stage the domain erasure event before forgetting. forget()
    // persists it in the erasure transaction — durable Art. 17 evidence
    // in-stream, visible to outbox consumers via the normal pipeline, and a
    // consumed sequence number that fences stale writers.
    customer.record_erasure();
    let mut customer = customers.forget(customer).await?;

    let row = sqlx::query!(
        r#"SELECT event_type, event FROM customer_events
           WHERE id = $1 ORDER BY sequence DESC LIMIT 1"#,
        id as CustomerId
    )
    .fetch_one(&pool)
    .await?;
    assert_eq!(row.event_type, "forgot");
    assert_eq!(row.event, serde_json::json!({ "type": "forgot" }));

    // The rebuilt entity carries the erasure event in its stream.
    assert!(
        customer
            .events()
            .iter_all()
            .any(|e| matches!(e, CustomerEvent::Forgot { .. }))
    );

    // Repeat forgets are legitimate: multiple erasure events = true history.
    customer.record_erasure();
    customers.forget(customer).await?;
    let count = sqlx::query!(
        r#"SELECT COUNT(*) AS "count!" FROM customer_events
           WHERE id = $1 AND event_type = 'forgot'"#,
        id as CustomerId
    )
    .fetch_one(&pool)
    .await?;
    assert_eq!(count.count, 2);

    Ok(())
}

#[tokio::test]
async fn staged_erasure_event_fences_stale_writers() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool);

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Stale Writer")
        .email("stale@example.com")
        .build()
        .unwrap();
    customers.create(new_customer).await?;

    // Two copies of the same entity: one gets forgotten, one goes stale.
    let mut stale = customers.find_by_id(id).await?;
    let mut fresh = customers.find_by_id(id).await?;

    // The staged erasure event consumes the next sequence number when
    // forget() persists it — that consumption is the concurrency fence.
    fresh.record_erasure();
    customers.forget(fresh).await?;

    let _ = stale.update_name("Resurrected Name");
    let err = customers
        .update(&mut stale)
        .await
        .expect_err("stale update after fenced forget must fail");
    assert!(
        err.was_concurrent_modification(),
        "expected ConcurrentModification, got: {err}"
    );

    // And the forgotten value stays forgotten.
    let reloaded = customers.find_by_id(id).await?;
    assert_eq!(reloaded.name, "[forgotten]");

    Ok(())
}

#[tokio::test]
async fn forget_without_staged_event_leaves_stale_writers_unfenced() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool);

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Unfenced")
        .email("unfenced@example.com")
        .build()
        .unwrap();
    customers.create(new_customer).await?;

    let mut stale = customers.find_by_id(id).await?;
    let fresh = customers.find_by_id(id).await?;

    // No erasure event staged: forget() consumes no sequence number.
    customers.forget(fresh).await?;

    // This pins the documented tradeoff of convention-based erasure: without
    // a staged erasure event there is nothing to fence a stale writer, so
    // its update() succeeds and re-persists the data that was just
    // forgotten. Stage a domain erasure event before forget() (see the
    // book chapter) to close this race.
    let _ = stale.update_name("Resurrected Name");
    customers
        .update(&mut stale)
        .await
        .expect("unfenced stale update succeeds — the accepted tradeoff");

    let reloaded = customers.find_by_id(id).await?;
    assert_eq!(reloaded.name, "Resurrected Name");

    Ok(())
}

#[tokio::test]
async fn forget_persists_staged_events_without_laundering() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool.clone());

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Pending Pat")
        .email("pending@example.com")
        .build()
        .unwrap();
    let mut customer = customers.create(new_customer).await?;

    // Mutate without persisting: the staged event carries a raw forgettable
    // value in memory. forget() persists it BEFORE deleting payload rows,
    // so the payload row that persistence inserts is hard-deleted in the
    // same transaction — the raw value never survives the erasure.
    let _ = customer.update_name("Unpersisted Name");
    let customer = customers.forget(customer).await?;
    assert_eq!(customer.name, "[forgotten]");

    // The staged event was persisted with null in the durable JSON...
    let row = sqlx::query!(
        r#"SELECT event->>'name' IS NULL AS "name_is_null!" FROM customer_events
           WHERE id = $1 AND event_type = 'name_updated'"#,
        id as CustomerId
    )
    .fetch_one(&pool)
    .await?;
    assert!(row.name_is_null);

    // ...and no payload row survived the erasure transaction.
    let payloads = sqlx::query!(
        r#"SELECT COUNT(*) AS "count!" FROM customers_forgettable_payloads
           WHERE entity_id = $1"#,
        id as CustomerId
    )
    .fetch_one(&pool)
    .await?;
    assert_eq!(payloads.count, 0);

    Ok(())
}

#[tokio::test]
async fn events_table_stores_null_for_live_forgettable_fields() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool.clone());

    let id = CustomerId::new();
    let new_customer = NewCustomer::builder()
        .id(id)
        .name("Nina Null")
        .email("nina@example.com")
        .build()
        .unwrap();

    let customer = customers.create(new_customer).await?;
    // The live entity has the value...
    assert_eq!(customer.name, "Nina Null");

    // ...but the durable event JSON must never contain the raw PII, even
    // while the entity is live: the `name` key holds JSON null.
    let row = sqlx::query!(
        r#"SELECT
             event->>'name' IS NULL AS "name_is_null!",
             event ? 'name' AS "name_key_present!",
             event->>'email' AS email
           FROM customer_events
           WHERE id = $1 AND sequence = 1"#,
        id as CustomerId
    )
    .fetch_one(&pool)
    .await?;
    assert!(row.name_is_null, "raw PII leaked into events table");
    assert!(row.name_key_present, "name key should be present (as null)");
    assert_eq!(row.email.as_deref(), Some("nina@example.com"));

    // The real value lives in the payloads table instead.
    let payload = sqlx::query!(
        r#"SELECT payload->>'name' AS "name!" FROM customers_forgettable_payloads
           WHERE entity_id = $1 AND sequence = 1"#,
        id as CustomerId
    )
    .fetch_one(&pool)
    .await?;
    assert_eq!(payload.name, "Nina Null");

    Ok(())
}

#[tokio::test]
async fn create_all_stores_payloads_and_nulls_events() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool.clone());

    let id1 = CustomerId::new();
    let id2 = CustomerId::new();
    let new_customers = vec![
        NewCustomer::builder()
            .id(id1)
            .name("Batch One")
            .email("batch1@example.com")
            .build()
            .unwrap(),
        NewCustomer::builder()
            .id(id2)
            .name("Batch Two")
            .email("batch2@example.com")
            .build()
            .unwrap(),
    ];

    let created = customers.create_all(new_customers).await?;
    assert_eq!(created.len(), 2);

    // Batch persistence must extract payloads exactly like the single path:
    // events hold null, payload rows hold the values.
    for (id, expected_name) in [(id1, "Batch One"), (id2, "Batch Two")] {
        let row = sqlx::query!(
            r#"SELECT event->>'name' IS NULL AS "name_is_null!" FROM customer_events
               WHERE id = $1 AND sequence = 1"#,
            id as CustomerId
        )
        .fetch_one(&pool)
        .await?;
        assert!(row.name_is_null, "raw PII leaked into events table (batch)");

        let payload = sqlx::query!(
            r#"SELECT payload->>'name' AS "name!" FROM customers_forgettable_payloads
               WHERE entity_id = $1 AND sequence = 1"#,
            id as CustomerId
        )
        .fetch_one(&pool)
        .await?;
        assert_eq!(payload.name, expected_name);

        // And loading hydrates the value back.
        let loaded = customers.find_by_id(id).await?;
        assert_eq!(loaded.name, expected_name);
    }

    Ok(())
}

#[tokio::test]
async fn find_all_works_with_forgettable() -> anyhow::Result<()> {
    let pool = helpers::init_pool().await?;
    let customers = Customers::new(pool);

    let id1 = CustomerId::new();
    let id2 = CustomerId::new();

    let c1 = NewCustomer::builder()
        .id(id1)
        .name("Dave")
        .email("dave@example.com")
        .build()
        .unwrap();
    let c2 = NewCustomer::builder()
        .id(id2)
        .name("Eve")
        .email("eve@example.com")
        .build()
        .unwrap();

    customers.create(c1).await?;
    customers.create(c2).await?;

    let all = customers.find_all::<Customer>(&[id1, id2]).await?;
    assert_eq!(all.len(), 2);
    assert_eq!(all[&id1].name, "Dave");
    assert_eq!(all[&id2].name, "Eve");

    // Forget one customer - forget consumes and returns the rebuilt, forgotten
    // entity.
    let c1 = customers.find_by_id(id1).await?;
    let c1 = customers.forget(c1).await?;
    assert_eq!(c1.name, "[forgotten]");

    let all = customers.find_all::<Customer>(&[id1, id2]).await?;
    assert_eq!(all[&id1].name, "[forgotten]");
    assert_eq!(all[&id2].name, "Eve");

    Ok(())
}