ferro_projection/lib.rs
1//! # ferro-projection
2//!
3//! Live read-model runtime: subscribe to domain events, persist per-key
4//! snapshots, broadcast deltas.
5//!
6//! **Not to be confused with [`ferro-projections`] (plural).** That crate
7//! is the Service Projection abstraction (`ServiceDef → IntentGraph →
8//! JsonUiRenderer`). This crate (`ferro-projection`, singular) is the
9//! live read-model runtime that subscribes to domain events, maintains a
10//! materialized state, and broadcasts deltas. The two abstractions are
11//! orthogonal — most apps will use both for different reasons.
12//!
13//! ferro-projection is the *live-read-model* primitive. [`ferro-events`]
14//! says *something happened*. [`ferro-broadcast`] says *something is
15//! visible to clients*. ferro-projection composes the two: events fold
16//! into per-key state, deltas land on `projection.{name}.{key}` channels.
17//!
18//! ## Per-key serialization
19//!
20//! ```text
21//! Event::dispatch() ─┐
22//! │
23//! ProjectionListener<P> ──┐
24//! │
25//! ▼
26//! ┌── per-key Mutex (DashMap<String, Arc<Mutex<()>>>) ──┐
27//! │ 1. load snapshot from projection_snapshots │
28//! │ 2. apply(&mut state, &event) → Delta │
29//! │ 3. upsert snapshot (state, version+1) │
30//! │ 4. broadcast on projection.{name}.{key} │
31//! └─────────────────────────────────────────────────────┘
32//! │
33//! ▼
34//! WebSocket clients receive the delta
35//! ```
36//!
37//! ## Schema and migration
38//!
39//! ferro-projection ships a SeaORM migration as
40//! [`CreateProjectionSnapshotsTable`]. Register it in your consumer-side
41//! `Migrator`:
42//!
43//! ```rust,ignore
44//! impl MigratorTrait for Migrator {
45//! fn migrations() -> Vec<Box<dyn MigrationTrait>> {
46//! vec![
47//! Box::new(ferro_projection::CreateProjectionSnapshotsTable),
48//! // ... your app migrations
49//! ]
50//! }
51//! }
52//! ```
53//!
54//! ## Operational footguns
55//!
56//! 1. **Broadcast failure does NOT roll back state.** If
57//! `Broadcast::send` returns `Err`, the snapshot row is already
58//! persisted; the runtime logs at `tracing::warn!` and surfaces
59//! `ProjectionError::Broadcast`. Subscribers reconcile by re-reading
60//! the snapshot.
61//! 2. **Single-instance assumption.** v0 assumes a single application
62//! instance owns each projection's listener. Multi-instance
63//! deployments must elect a single projection-runner node or accept
64//! last-writer-wins behavior on concurrent applies to the same key
65//! from different nodes.
66//! 3. **`register` is not idempotent on `Arc` identity.** Calling
67//! `Arc<ProjectionRuntime<P>>::register()` twice registers two
68//! listeners — both fire on each dispatch (same semantic as
69//! Laravel's `Event::listen`). Register once at app startup.
70//!
71//! [`ferro-projections`]: https://docs.rs/ferro-projections
72//! [`ferro-events`]: https://docs.rs/ferro-events
73//! [`ferro-broadcast`]: https://docs.rs/ferro-broadcast
74
75mod entity;
76mod error;
77mod key;
78mod listener;
79mod migration;
80mod projection;
81mod runtime;
82
83pub use error::ProjectionError;
84pub use key::ProjectionKey;
85pub use migration::Migration as CreateProjectionSnapshotsTable;
86pub use projection::Projection;
87pub use runtime::ProjectionRuntime;
88
89// SeaORM entity re-exports for consumers needing native SeaORM query access.
90pub use entity::{
91 ActiveModel as ProjectionSnapshotActiveModel, Entity as ProjectionSnapshotEntity,
92 Model as ProjectionSnapshotModel,
93};