skilj_core/plugin/mod.rs
1//! The public plugin API - the one module every consuming application's
2//! own code touches directly. See [docs/architecture.md §1](../../../docs/architecture.md#plugin-api-decide-project) for the full
3//! reasoning behind this shape.
4
5use crate::event_store::{Event, MissedOccurrencePolicy};
6use crate::shared::{
7 CommandDecision, PrivateField, QueryItemMapping, SensitiveField, Tag, TagMapping,
8};
9use schemars::JsonSchema;
10use serde::{de::DeserializeOwned, Serialize};
11
12/// See `CommandType::required_role`'s own doc comment and docs/
13/// architecture.md §1.3.1 - the one deliberate proc-macro exception to
14/// this module's otherwise macro-free plugin API. Re-exported here so a
15/// consumer only ever needs `skilj_core::plugin::{CommandType,
16/// requires_role}`, not a direct dependency on `skilj-macros` itself.
17pub use skilj_macros::requires_role;
18
19/// The fallback every plugin trait's `BOUNDED_CONTEXT` associated const
20/// carries - see that const's own doc comment on `EventType`/
21/// `CommandType`/`Projection`. Also what `skilj::SkiljBuilder::builder()`
22/// seeds `current_bounded_context` with, so a single-bounded-context app
23/// never has to call `.bounded_context(...)` at all: every `.event_type::<T>()`/
24/// `.command_type::<T>()`/`.projection::<T>()` call registers under this
25/// name until something changes it.
26pub const DEFAULT_BOUNDED_CONTEXT: &str = "default";
27
28/// Bridges a raw, type-erased `event_store::Event` into the
29/// strongly-typed, hand-written per-bounded-context event enum
30/// `decide()`/`project()` pattern-match over (§1.4's `BankingEvent`, say),
31/// implemented once per bounded context, on that enum. See docs/
32/// architecture.md §1.6 for the full reasoning; §1.7's decider bridge is
33/// the one caller of `try_from_event` in this crate.
34///
35/// ```ignore
36/// impl BoundedContextEvent for BankingEvent {
37/// fn try_from_event(event: &Event) -> Option<Result<Self, serde_json::Error>> {
38/// match event.event_type.name.as_str() {
39/// "MoneyDeposited" => Some(serde_json::from_str(&event.payload).map(Self::MoneyDeposited)),
40/// "MoneyWithdrawn" => Some(serde_json::from_str(&event.payload).map(Self::MoneyWithdrawn)),
41/// _ => None,
42/// }
43/// }
44/// }
45/// ```
46pub trait BoundedContextEvent: Sized {
47 /// `None` when `event.event_type.name` doesn't match any variant this
48 /// enum declares - never expected in practice (a caller only ever
49 /// passes this bounded context's own events), so `None` is a
50 /// defensive case, not a designed-for one. `Some(Err(..))` when the
51 /// stored payload doesn't deserialize into the matched variant's
52 /// payload type - narrower than it used to be (an externally- or
53 /// directly-created event's own payload is now schema-checked before
54 /// it's ever stored), but still reachable: a command-triggered or
55 /// system-triggered event's payload came from `decide()`/
56 /// `scheduled_payload`, which are never schema-checked at all (see
57 /// the note above `entity CommandType`'s "Payload schema shape" in
58 /// specs/skilj.allium), and even a schema-valid payload can still be
59 /// stricter than the compiled Rust type expects.
60 fn try_from_event(event: &Event) -> Option<Result<Self, serde_json::Error>>;
61}
62
63/// One step in an `upcast_payload` chain - see that function's own doc
64/// comment. `to_version` is the schema_version this step's `transform`
65/// produces: a payload whose own `Metadata.version` is strictly less
66/// than `to_version` needs this step applied before whichever step (or
67/// final deserialization) comes next.
68#[derive(Clone, Copy)]
69pub struct UpcastStep {
70 /// The schema version `transform` produces.
71 pub to_version: i64,
72 /// Rewrites a payload written before `to_version` into that version's
73 /// shape.
74 pub transform: fn(serde_json::Value) -> serde_json::Value,
75}
76
77/// Sugar around the one pattern a hand-written `BoundedContextEvent::
78/// try_from_event` already has every ingredient for: `event.metadata.version`
79/// records the schema_version a payload was actually written under
80/// (specs/skilj.allium's own note above, "that is what Metadata.version
81/// is for"), and nothing stops a `try_from_event` arm from branching on
82/// it before deserializing. This is that branch, generalised into a
83/// declared chain of pure JSON transforms instead of a repeated
84/// `match metadata.version { ... }` per event type. See
85/// [docs/architecture.md §33](../../../docs/architecture.md#payload-upcasting) for the full "why not first-class
86/// upcasting" investigation this is the one built piece of.
87///
88/// Not a replacement for `schema_is_backwards_compatible`'s additive-only
89/// contract, and doesn't need to be: a genuinely reshaping change
90/// (rename, retype, split/merge a field) still can't be registered as a
91/// revision of an existing type - `Self::Payload`'s own derived JSON
92/// Schema only ever has to describe the *current*, already-reshaped
93/// value, because this runs entirely on the raw `serde_json::Value`
94/// before `serde_json::from_value::<T>` ever sees it. The transform
95/// chain lives in application code only; nothing about the registered
96/// schema, `RegisterEventType`, or `RegisterCommandType` changes.
97///
98/// `chain` is walked once, in the order given, applying every step whose
99/// `to_version` is strictly greater than `written_at_version` - a
100/// payload written at version 1 runs every step in a `[..to 2, ..to 3]`
101/// chain, one written at version 2 runs only the second, one written at
102/// version 3 (or later) runs none. Steps are expected in ascending
103/// `to_version` order; this does not sort them - an out-of-order chain
104/// applies transforms in the wrong sequence, the same "caller's own
105/// responsibility" register `consumed_event_types()` already trusts the
106/// caller to get right.
107///
108/// ```ignore
109/// // MoneyDeposited moved from a float-dollar `amount` (schema_version 1)
110/// // to an integer-cent `amount_cents` (schema_version 2) - a genuine
111/// // reshape schema_is_backwards_compatible would reject as a revision
112/// // of the same type, handled here entirely in application code:
113/// const MONEY_DEPOSITED_UPCASTS: &[UpcastStep] = &[UpcastStep {
114/// to_version: 2,
115/// transform: |mut v| {
116/// if let Some(amount) = v.get("amount").and_then(|a| a.as_f64()) {
117/// if let Some(obj) = v.as_object_mut() {
118/// obj.remove("amount");
119/// obj.insert("amount_cents".into(), ((amount * 100.0).round() as i64).into());
120/// }
121/// }
122/// v
123/// },
124/// }];
125///
126/// "MoneyDeposited" => Some(
127/// skilj_core::plugin::upcast_payload(
128/// &event.payload,
129/// event.metadata.version,
130/// MONEY_DEPOSITED_UPCASTS,
131/// )
132/// .map(Self::MoneyDeposited),
133/// ),
134/// ```
135pub fn upcast_payload<T: DeserializeOwned>(
136 payload: &str,
137 written_at_version: i64,
138 chain: &[UpcastStep],
139) -> Result<T, serde_json::Error> {
140 let mut value: serde_json::Value = serde_json::from_str(payload)?;
141 for step in chain {
142 if written_at_version < step.to_version {
143 value = (step.transform)(value);
144 }
145 }
146 serde_json::from_value(value)
147}
148
149/// One command type a bounded context registers.
150///
151/// `decide()` is synchronous and pure: it receives only `payload` and
152/// `matching_events`, nothing else - no database access, no I/O. That's
153/// deliberate (§1.1): `ProcessCommand`'s optimistic-then-locked retry may
154/// call `decide()` more than once per submission.
155pub trait CommandType {
156 /// The payload shape, and the source of its JSON Schema - derived via
157 /// `schemars`, never hand-written (§1.2).
158 type Payload: Serialize + DeserializeOwned + JsonSchema;
159
160 /// The bounded context's own generated event enum, one variant per
161 /// registered `EventType`. `matching_events` is typed against this so
162 /// a missed match arm is a compile error, not a silently-ignored
163 /// event type (§1.4). `: BoundedContextEvent` is what lets §1.7's
164 /// decider bridge convert this bounded context's raw, stored `Event`s
165 /// into it before calling `decide()`.
166 type Event: BoundedContextEvent;
167
168 /// The command type's name, unique within its bounded context - what
169 /// GraphQL, REST and `EventSpec::event_type` name it by.
170 const NAME: &'static str;
171
172 /// Which bounded context `skilj::SkiljBuilder::auto_register()`
173 /// registers this command type under - see `skilj_macros::auto_register`'s
174 /// own doc comment for the `#[auto_register]` attribute that reads
175 /// this. Irrelevant to manual `.bounded_context(name).command_type::<T>()`
176 /// chaining, which ignores this const entirely and keeps working
177 /// exactly as before.
178 ///
179 /// Defaults to `DEFAULT_BOUNDED_CONTEXT` ("default"), so a
180 /// single-bounded-context app never has to override this at all. A
181 /// multi-context app points it at its own module-level
182 /// `BOUNDED_CONTEXT` const (the same one `skilj-demo`'s `banking`/
183 /// `courses` modules already declare), e.g.
184 /// `const BOUNDED_CONTEXT: &'static str = BOUNDED_CONTEXT;`.
185 const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
186
187 /// The consistency tags this command's payload carries: each maps a tag
188 /// key to a payload field. `decide()` is handed every event sharing at
189 /// least one of the resulting tags. Empty by default.
190 fn tag_mappings() -> Vec<TagMapping> {
191 Vec::new()
192 }
193
194 /// See `plugin::EventType::owner_tag_key`'s own doc comment - identical
195 /// role here: which of `tag_mappings()`'s own keys names this command
196 /// type's "owner" dimension, `None` (the default) for one with no such
197 /// notion. Registered and validated exactly the same way
198 /// (`RegisterCommandType`'s own `valid_owner_tag_key`), and read by
199 /// `event_store::command_owner_scope_satisfied` - `Command.consistency_tags`'
200 /// own sibling check to `event_owner_scope_satisfied`'s `Event.tags`.
201 /// Cross-tenant read fix (docs/architecture.md's own write-up of these
202 /// passes).
203 fn owner_tag_key() -> Option<&'static str> {
204 None
205 }
206
207 /// Payload fields encrypted per subject, so `forgetSubject` makes them
208 /// unreadable. Empty by default.
209 fn sensitive_fields() -> Vec<SensitiveField> {
210 Vec::new()
211 }
212
213 /// See `EventType::private_fields()`'s own doc comment - identical
214 /// role, for `Command.payload` instead of `Event.payload`.
215 fn private_fields() -> Vec<PrivateField> {
216 Vec::new()
217 }
218
219 /// Opt-in: submitting over GraphQL with a write-level grant is always
220 /// permitted; triggering over REST with a `CommandToken` needs this.
221 fn rest_trigger_allowed() -> bool {
222 false
223 }
224
225 /// Opt-in approval flow (issue #98, docs/architecture.md
226 /// §214): when `true`, a submission - REST or GraphQL - creates a
227 /// pending `CommandProposal` instead of running `decide()`. An
228 /// approver with an active admin-level `RoleAccessMapping` on the
229 /// bounded context then approves it over GraphQL, which runs the
230 /// ordinary submission pipeline against the state at that time.
231 ///
232 /// A command may still be rejected by `decide()` at approval time;
233 /// the proposal is then recorded as rejected, with the decision's own
234 /// reason. `false` by default, so every existing command type keeps
235 /// its immediate-submission behaviour.
236 fn requires_approval() -> bool {
237 false
238 }
239
240 /// Narrows who may approve a `requires_approval()` command type, on
241 /// top of the admin grant every approver needs: `Some(name)` requires
242 /// the approver's `Role.name` to equal `name`, `None` (the default)
243 /// lets any active admin-level mapping approve. Ignored for a type
244 /// that does not require approval.
245 fn approval_role() -> Option<&'static str> {
246 None
247 }
248
249 /// An optional approval deadline, in seconds from proposal creation.
250 /// `None` (the default) means a proposal stays approvable until it is
251 /// approved or rejected. An expired proposal is recorded as expired
252 /// and cannot be approved.
253 fn approval_deadline_seconds() -> Option<i64> {
254 None
255 }
256
257 /// Whether the account that proposed a command may also approve it.
258 /// `false` (the default) requires a different approver - a different
259 /// human, not merely a different Role row; rejecting - withdrawing -
260 /// one's own proposal is always allowed. Ignored for a type that does
261 /// not require approval.
262 fn approval_allows_self_approval() -> bool {
263 false
264 }
265
266 /// An extra caller-facing gate, on top of the ordinary write-level
267 /// `RoleAccessMapping` check: `None` (the default) means no extra
268 /// restriction. Set this by writing `#[requires_role("name")]`
269 /// directly above the `impl CommandType for ...` block - see
270 /// `requires_role`'s own doc comment - rather than overriding this
271 /// method by hand; the attribute is what makes the requirement read
272 /// as part of the command's own declaration.
273 ///
274 /// Not a spec-level concept, not persisted anywhere: `Role.name` has
275 /// no uniqueness guarantee in `specs/skilj.allium` (see `entity
276 /// Role`), so this is only as safe as the deployment's own discipline
277 /// keeping role names meaningful and non-colliding - a `skilj-core`
278 /// concern, not something the engine itself enforces. Checked
279 /// exclusively by `skilj-graphql`'s own mutation resolver (docs/
280 /// architecture.md §1.3.1, [§8](../../../docs/architecture.md#open-for-a-future-pass) item 5) - REST triggering is untouched,
281 /// since `CommandToken` is already its own, separate per-token grant.
282 fn required_role() -> Option<&'static str> {
283 None
284 }
285
286 /// Decides from the events the command's query selects. Implement
287 /// this, or [`decide_with`](CommandType::decide_with) when the
288 /// decision also needs a snapshot's state; the framework only calls
289 /// `decide_with`, whose default calls this. The default here refuses
290 /// every command, for a type that implements neither.
291 fn decide(_payload: &Self::Payload, _matching_events: &[Self::Event]) -> CommandDecision {
292 CommandDecision::Rejected {
293 reason: "this CommandType implements neither decide() nor decide_with()".to_string(),
294 kind: "decide_not_implemented".to_string(),
295 }
296 }
297
298 /// What the framework calls to decide a command
299 /// (docs/architecture.md §202): `decision.events()` is what
300 /// `matching_events` is to `decide()`, and `decision.snapshot::<S>(value)`
301 /// is snapshot `S`'s state for a tag value one of the query's
302 /// [`QueryItemMapping::snapshot`] items derived, already brought up to
303 /// date - nothing to fold. The default calls `decide()` with the
304 /// events, so a type that needs no snapshot implements `decide()` alone.
305 fn decide_with(
306 payload: &Self::Payload,
307 decision: &Decision<'_, Self::Event>,
308 ) -> CommandDecision {
309 Self::decide(payload, decision.events())
310 }
311
312 /// Which events `decide()` receives, and which later events conflict
313 /// with it (docs/architecture.md §198, the DCB query model). Each item
314 /// matches events of its types (any type when it names none) that
315 /// carry all of its tags; `matching_events` is every event matching
316 /// any item, in sequence order, except that an item with `latest`
317 /// contributes only that many of its matches, the newest
318 /// (`QueryItemMapping::latest()` for one, `last(n)` for n).
319 ///
320 /// Empty (the default) means one item per `tag_mappings()` entry, any
321 /// type, every match: every event carrying any of the command's tags,
322 /// as before queries existed. A narrower query reads less, and also
323 /// conflicts less: an event carrying the command's tag but matching
324 /// no item no longer makes it decide again.
325 ///
326 /// `vec![QueryItemMapping::nothing()]` is a command whose `decide()`
327 /// needs no history (docs/architecture.md §201): it reads no events,
328 /// and nothing committed after it is read conflicts with it.
329 ///
330 /// `build()` refuses a query naming an event type its bounded context
331 /// doesn't register in the same builder, a tag mapping that isn't one
332 /// of `tag_mappings()`, an item with neither types nor tags other than
333 /// a lone `nothing()`, and a query together with `snapshot()`.
334 fn consistency_query() -> Vec<QueryItemMapping> {
335 Vec::new()
336 }
337
338 /// Superseded by snapshot items in `consistency_query()`
339 /// (`QueryItemMapping::snapshot`, docs/architecture.md §202), which
340 /// work with any number of tags and hand `decide_with()` the state
341 /// already brought up to date; kept working for existing command
342 /// types.
343 ///
344 /// Opt-in accelerator for `decide()` against a large tag-scoped
345 /// history ([docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events)'s "Problem 2") - names the
346 /// `Snapshot::NAME` this command type's own decide() can fold
347 /// forward from instead of replaying every matching event. `None`
348 /// (the default) means no snapshot; every existing `CommandType`
349 /// impl is completely unaffected.
350 ///
351 /// Only actually used when this command type derives exactly the
352 /// one tag the named `Snapshot::TAG_KEY` is - a multi-tag command
353 /// that names a snapshot anyway is a harmless misconfiguration,
354 /// silently ignored (falls back to the ordinary `decide()` path),
355 /// not a hard error - see `decide_from_snapshot`'s own doc comment
356 /// for the *reachable* misconfiguration this trait can't catch for
357 /// you.
358 fn snapshot() -> Option<&'static str> {
359 None
360 }
361
362 /// Called instead of `decide()` when `snapshot()` names a real,
363 /// tag-matching `Snapshot` (see that method's own doc comment).
364 /// `snapshot_state_json` is `Snapshot::State::default()`,
365 /// JSON-encoded, when no snapshot has been written yet for this tag
366 /// value, or when a stored one's own `snapshot_version` no longer
367 /// matches `Snapshot::VERSION` - the model changed, so it's treated
368 /// as absent rather than trusted; `events_since_snapshot` is then
369 /// simply everything for this tag, the same set `decide()` would
370 /// have seen. Correctness obligation: this must reach the identical
371 /// `CommandDecision` `decide()` would over the *full* matching_events
372 /// for the same payload - `snapshot_state_json` plus
373 /// `events_since_snapshot` is meant to be exactly equivalent
374 /// information, not an approximation.
375 ///
376 /// `snapshot_state_json` deliberately isn't a typed `Snapshot::State`,
377 /// since linking `CommandType` to one specific `Snapshot` impl at
378 /// the type level needs either a new required associated type
379 /// (breaking every existing `CommandType` impl) or a *defaulted*
380 /// one (`associated_type_defaults`, nightly-only). Deserialise it
381 /// yourself, the same way `tag_mappings()`'s own `field` is already
382 /// just a string, unchecked against `Payload` at compile time.
383 ///
384 /// The default implementation is a safe, non-panicking rejection -
385 /// only ever reached if a `CommandType` overrides `snapshot()`
386 /// without also overriding this: a real misconfiguration (unlike
387 /// the multi-tag case above), but one this trait can still fail
388 /// safely on rather than silently producing a wrong decision.
389 ///
390 /// `context` (issue #97) is the same [`DecisionContext`]
391 /// `decide_with` reads through [`Decision::context`].
392 fn decide_from_snapshot(
393 _payload: &Self::Payload,
394 _snapshot_state_json: &str,
395 _events_since_snapshot: &[Self::Event],
396 _context: &DecisionContext,
397 ) -> CommandDecision {
398 CommandDecision::Rejected {
399 reason: "this CommandType declares snapshot() but never overrides \
400 decide_from_snapshot()"
401 .to_string(),
402 kind: "snapshot_misconfigured".to_string(),
403 }
404 }
405}
406
407/// One event type a bounded context registers. Unlike `CommandType`/
408/// `Projection`, there's no behaviour to plug in here - an `EventType` is
409/// pure declaration (its schema, its tag/sensitivity mappings, which
410/// origins may create it).
411pub trait EventType {
412 /// The payload shape, and the source of its JSON Schema - derived via
413 /// `schemars`, never hand-written.
414 type Payload: Serialize + DeserializeOwned + JsonSchema;
415
416 /// The event type's name, unique within its bounded context - what
417 /// `EventSpec::event_type` and the API surfaces name it by.
418 const NAME: &'static str;
419
420 /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment - identical
421 /// role and default here, for `skilj::SkiljBuilder::auto_register()`'s
422 /// `EventType` side.
423 const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
424
425 /// The tags this event's payload carries: each maps a tag key to a payload
426 /// field. A command whose tags overlap an event's sees that event in
427 /// `decide()`. Empty by default.
428 fn tag_mappings() -> Vec<TagMapping> {
429 Vec::new()
430 }
431
432 /// Which of `tag_mappings()`'s own keys names this type's "owner"
433 /// dimension, if it has one - `Some("company")` for a type whose
434 /// events belong to different tenants sharing one bounded context,
435 /// `None` (the default) for a type with no such notion. Unlike
436 /// `plugin::Projection::OWNER_TAG_KEY` (deliberately Rust-only, no
437 /// spec field), this one is registered and validated:
438 /// `RegisterEventType`'s own `valid_owner_tag_key` requires it be
439 /// null or name a real `tag_mappings()` key, since `EventType.tag_mappings`
440 /// is already a real, spec'd, registered field this joins rather than
441 /// a fresh Rust-only concept. Cross-tenant read fix
442 /// (docs/architecture.md's own write-up of these passes).
443 fn owner_tag_key() -> Option<&'static str> {
444 None
445 }
446
447 /// Payload fields encrypted per subject, so `forgetSubject` makes them
448 /// unreadable. Empty by default.
449 fn sensitive_fields() -> Vec<SensitiveField> {
450 Vec::new()
451 }
452
453 /// See `value PrivateField`'s own doc comment - a plain read-time
454 /// redaction rule, opt-in per field, default empty like
455 /// `sensitive_fields()` above. Private-field mechanism
456 /// (docs/architecture.md's own write-up of this pass).
457 fn private_fields() -> Vec<PrivateField> {
458 Vec::new()
459 }
460
461 /// Opt-in: whether an external system holding an external-event token
462 /// (`createExternalEventToken`) may create this event over REST, as the
463 /// broker bridges do. `false` by default.
464 fn external_creation_allowed() -> bool {
465 false
466 }
467
468 /// Opt-in: whether this event may be created without a command, by a
469 /// caller holding a direct-creation token (`createDirectCreationToken`).
470 /// `false` by default.
471 fn direct_creation_allowed() -> bool {
472 false
473 }
474
475 /// Opt-in: whether event read tokens may be minted for this event type
476 /// (`createEventReadToken`), so REST can serve its events. `false` by
477 /// default.
478 fn event_read_allowed() -> bool {
479 false
480 }
481
482 /// Opt-in: `rule CreateSystemEvent`'s own gate, `EventType.
483 /// system_triggered_allowed`. `false` by default, like every other
484 /// creation-path flag on this trait - a type that never overrides
485 /// this three plus `system_triggered_schedule`/`missed_occurrence_policy`
486 /// is never scheduled at all, so `scheduled_payload` below is never
487 /// called for it.
488 fn system_triggered_allowed() -> bool {
489 false
490 }
491
492 /// The 7-field Quartz-dialect cron expression (`cron` crate) the
493 /// scheduler evaluates - required, and checked at registration time
494 /// (see `Error::MissingScheduleOrPolicy`), when
495 /// `system_triggered_allowed()` is `true`. `None` otherwise.
496 fn system_triggered_schedule() -> Option<String> {
497 None
498 }
499
500 /// See `enum MissedOccurrencePolicy`. Deliberately no default that
501 /// silently picks one policy over another - the user's own call on
502 /// this: "either way can be potentially dangerous", so a type opting
503 /// into scheduling must say which it means, explicitly, by
504 /// overriding this method; `register_event_type`'s own guard rejects
505 /// registration outright if this is still `None` while
506 /// `system_triggered_allowed()` is `true`.
507 fn missed_occurrence_policy() -> Option<MissedOccurrencePolicy> {
508 None
509 }
510
511 /// `scheduled_payload(event_type)` - the black box `rule
512 /// CreateSystemEvent` names. Called at most once per eligible
513 /// occurrence (see `event_store::create_system_event`'s own doc
514 /// comment on why it's never called for one that's about to be
515 /// rejected), never for a type that isn't `system_triggered_allowed`.
516 /// Panics if reached without being overridden - a type opting into
517 /// scheduling without ever producing a payload for it is a genuine
518 /// programming error, not a runtime condition to handle gracefully.
519 fn scheduled_payload() -> Self::Payload {
520 unimplemented!(
521 "{}::scheduled_payload() must be overridden - this event type is registered with \
522 system_triggered_allowed() = true, so the scheduler needs a payload for every \
523 occurrence it fires",
524 Self::NAME
525 )
526 }
527}
528
529/// Type-erased access to a bounded context's own `EventType::
530/// scheduled_payload()`, for the background scheduler in `db::
531/// fire_system_event` - the `EventType` equivalent of `CommandDispatcher`/
532/// `ProjectionDispatcher` above `Skilj` implements over its own compiled-in
533/// registry.
534pub trait EventDispatcher: Send + Sync {
535 /// `scheduled_payload()`'s own output, JSON-serialised - `None` when
536 /// no `(bounded_context, event_type)` pair matches anything
537 /// registered in this process, the same "pair isn't registered at
538 /// all" convention `CommandDispatcher::dispatch`/`ProjectionDispatcher::
539 /// keys` already use. A genuine misconfiguration when it happens for
540 /// a type the database itself marks `system_triggered_allowed` (see
541 /// `@guarantee ScheduleStateIsShared`'s own expectation that every
542 /// instance in a cluster registers the same scheduled types) -
543 /// `db::fire_system_event`'s caller treats it as a skip, not a panic,
544 /// the same tolerance `catch_up_bounded_context` already extends to
545 /// an unregistered projection dispatcher.
546 fn scheduled_payload(&self, bounded_context: &str, event_type: &str) -> Option<String>;
547}
548
549/// One projection a bounded context registers.
550///
551/// `project()` folds exactly one event into this projection's stored
552/// state - synchronous, like `decide()`, and for the same reason (§1.1).
553pub trait Projection {
554 /// The state `project()` folds events into, stored as JSON. `Default` is
555 /// the state before any event.
556 type State: Serialize + DeserializeOwned + JsonSchema + Default;
557
558 /// Same generated per-bounded-context event enum `CommandType::Event`
559 /// uses - a projection folds any event type it consumes, not just
560 /// one. Same `: BoundedContextEvent` bound and reasoning.
561 type Event: BoundedContextEvent;
562
563 /// The projection's name, unique within its bounded context - what
564 /// `queryProjection` names it by.
565 const NAME: &'static str;
566
567 /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment - identical
568 /// role and default here, for `skilj::SkiljBuilder::auto_register()`'s
569 /// `Projection` side.
570 const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
571
572 /// The `EventType::NAME`s this projection actually folds - `RegisterProjection`'s
573 /// own `consumed_event_types`. No default: unlike `tag_mappings()`/
574 /// `sensitive_fields()`, there's no reasonable "consumes nothing"
575 /// default for a projection to fall back to, and Rust has no way to
576 /// infer this from `project()`'s own body (which variants of
577 /// `Self::Event` it actually matches on isn't something the type
578 /// system exposes) - so it's declared explicitly, the same
579 /// "must specify" register `NAME` itself already uses.
580 fn consumed_event_types() -> Vec<&'static str>;
581
582 /// Whether this projection updates inline, in the same transaction as
583 /// the events it consumes (`true`), or via a background consumer
584 /// (`false`, the default) - see `Projection.sync` in the spec.
585 fn sync() -> bool {
586 false
587 }
588
589 /// Which instance(s) of this projection `event` updates - see the
590 /// instance-data note on `entity Projection` in the spec. Defaults to
591 /// one constant, unnamed key (`""`) - the "single shared value" case
592 /// every projection built before this pass already is, needing no
593 /// change at all to keep working exactly as it already does.
594 /// Returning more than one key (a transfer event naming both the
595 /// giver's and the receiver's own account id, say) folds this event
596 /// into each of those instances independently, via its own call to
597 /// `project()` - see that method's own doc comment for the `key`
598 /// parameter it's handed each time.
599 fn keys(_event: &Self::Event) -> Vec<String> {
600 vec![String::new()]
601 }
602
603 /// The tag key (one of some consumed event type's own `tag_mappings`
604 /// keys) that names this projection's "owner" dimension, if it has
605 /// one - `Some("company")` for a projection whose instances belong to
606 /// different tenants sharing one bounded context, `None` (the
607 /// default) for a projection with no such notion, e.g. `keys()`
608 /// already being a person's own id or the projection being unkeyed.
609 /// Deliberately a Rust-only implementation detail with no spec entity
610 /// field and no registration/admin-visible surface of its own - the
611 /// identical treatment `Snapshot::TAG_KEY` already gets, and for the
612 /// same reason: `RoleAccessMapping.scope` is what an admin actually
613 /// grants and sees, not this.
614 ///
615 /// When set, each instance's own "owner" value is derived
616 /// automatically at fold time from whichever event touched it: the
617 /// value of the tag on `event.tags` whose key matches this one, if
618 /// present. An event lacking that tag leaves an already-established
619 /// owner untouched rather than clearing it. `query_projection`
620 /// rejects a `scope`-restricted `RoleAccessMapping` whose `scope`
621 /// does not match an instance's own derived owner - see that
622 /// function's own doc comment and specs/skilj.allium's
623 /// `owner_scope_satisfied`.
624 const OWNER_TAG_KEY: Option<&'static str> = None;
625
626 /// The capability a grant must hold to query this projection at all
627 /// (`RoleAccessMapping::capabilities`), if any - `None` (the default,
628 /// and every projection that predates this) for one anybody with read
629 /// access may query. A superadmin grants or revokes it, or an admin
630 /// allowed to delegate it (`RoleAccessMapping::delegable`); see
631 /// `access_control::grant_capability`. A name is lowercase letters,
632 /// digits, `_` and `-`, up to 64 characters
633 /// (`access_control::valid_capability_name`).
634 ///
635 /// Unlike `OWNER_TAG_KEY`, which is derived per-instance from folded
636 /// event tags, this is a fixed, whole-projection gate: it names no
637 /// dimension to look up per instance, just one required capability, so
638 /// either every instance of this projection is reachable by a holder
639 /// or none are - never a per-instance answer. Composes with
640 /// `OWNER_TAG_KEY` rather than replacing it: a projection may declare
641 /// both (staff-only *and* company-scoped), and `query_projection`
642 /// checks both independently. Nothing implies a capability: not an
643 /// admin level, not a superadmin Role, not a Role that happens to be
644 /// named like it. To a grant without it the projection answers as if
645 /// it did not exist. See that function's own doc comment and
646 /// specs/skilj.allium's `CapabilityGatedWhenDeclared`.
647 ///
648 /// Replaces `TEAM_ONLY`, which matched `Role.name` exactly and so
649 /// needed every member of a team to share one Role name (issue #17).
650 /// The requirement is read from the compiled projection; an
651 /// admin-readable copy registered alongside it is a follow-up.
652 const REQUIRES_CAPABILITY: Option<&'static str> = None;
653
654 /// How many partitions this projection's own `keys()` values hash
655 /// into for async catch-up (issue #25,
656 /// docs/architecture.md §51) - `1` (the default, and every
657 /// projection that predates this) means every key hashes to the
658 /// same partition, so `catch_up_bounded_context` takes its original,
659 /// unpartitioned code path unchanged and order is exactly as it
660 /// already was. A value greater than `1` lets multiple skilj
661 /// instances each independently claim and fold a different slice of
662 /// this projection's own keys concurrently, instead of every
663 /// instance redundantly redoing all of it - see
664 /// `catch_up_partitioned_projection`'s own doc comment for the
665 /// mechanism. Deliberately Rust-only, no spec entity field and no
666 /// registration/admin-visible surface, the identical treatment
667 /// `OWNER_TAG_KEY`/`REQUIRES_CAPABILITY` above already get: it's a pure
668 /// work-distribution detail, invisible to what `project()` actually
669 /// folds, so changing it between deploys needs no rebuild (unlike
670 /// `schema`/`consumed_event_types`/`sync`, which change what gets
671 /// folded - see `register_projection`'s own rebuild-trigger logic).
672 /// Meaningful only for `sync() == false`; inert (never validated
673 /// against, never rejected) for a sync one, the same stance
674 /// `OWNER_TAG_KEY`/`REQUIRES_CAPABILITY` already take toward combinations that
675 /// don't make sense rather than erroring on them.
676 const PARTITION_COUNT: u32 = 1;
677
678 /// `key` is which instance is currently being folded - one of
679 /// `Self::keys(event)`'s own return values, handed back so `project()`
680 /// can tell them apart when an event touches more than one (compare
681 /// `key` against the event's own fields to decide, e.g., whether this
682 /// call is crediting or debiting). Ignored entirely by a projection
683 /// that never overrides `keys()` - it only ever sees the one constant
684 /// `""` instance, the same single fold every existing projection
685 /// already does.
686 fn project(state: &mut Self::State, event: &Self::Event, key: &str);
687}
688
689/// A folded, tag-scoped accelerator for `CommandType::decide()` against
690/// a large `matching_events` history ([docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events)'s
691/// "Problem 2") - `CommandType::snapshot()`'s own doc comment is where a
692/// command type opts in.
693///
694/// Deliberately **not** `Projection`, even though the shape rhymes
695/// (`fold` vs. `project`, `TAG_KEY` vs. `keys()`, `VERSION` vs. a
696/// restage): `Projection`s are eventually-consistent, rebuildable/
697/// restageable, Admin-exposed read-model infrastructure with no
698/// obligation to the timing/correctness bar a DCB consistency check
699/// needs - letting `decide()`'s own inputs depend on `Projection`
700/// machinery would mean an operator rebuilding a projection for
701/// ordinary read-model reasons could silently corrupt what `decide()`
702/// sees. `Snapshot` is its own trait, own storage
703/// (`{schema}.snapshots`/`{schema}.snapshot_progress`), own background
704/// catch-up task, own inspection endpoint - no shared code path a
705/// read-model change could destabilise.
706///
707/// Scoped to one tag key, holding a state per value of it. A command
708/// type of any shape uses it through snapshot items in its consistency
709/// query (`QueryItemMapping::snapshot`, docs/architecture.md §202): any
710/// number of them, over one snapshot or several, next to ordinary items,
711/// each handing `decide_with()` the state for one value. An event
712/// carrying several values of the key - a transfer between two accounts -
713/// is folded into each value's state, through `fold_for`, which is told
714/// the value.
715///
716/// One `Snapshot` definition is shared by every `CommandType` that tags
717/// on its `TAG_KEY` and opts in - `banking.rs`'s `DepositMoney`/
718/// `WithdrawMoney` both tag on `account` and both want the same running
719/// balance, so they share one `AccountBalanceSnapshot` rather than each
720/// computing their own, free to drift apart.
721pub trait Snapshot {
722 /// The state `fold()` builds per tag value, stored as JSON. `Default` is
723 /// the state before any event.
724 type State: Serialize + DeserializeOwned + JsonSchema + Default;
725
726 /// Same generated per-bounded-context event enum `CommandType::Event`/
727 /// `Projection::Event` use - a snapshot folds any event type
728 /// carrying its `TAG_KEY`, not just one.
729 type Event: BoundedContextEvent;
730
731 /// The snapshot's name, unique within its bounded context - what
732 /// `SnapshotState::snapshot` and `Decision::snapshot` name it by.
733 const NAME: &'static str;
734
735 /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment - identical
736 /// role and default here, for `skilj::SkiljBuilder::auto_register()`'s
737 /// `Snapshot` side.
738 const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
739
740 /// The single tag key this snapshot is scoped to - see this trait's
741 /// own doc comment for why only one. The background catch-up task
742 /// derives which tag *value* an event belongs to generically, from
743 /// `event.tags`, so there's no `keys()`-equivalent method to
744 /// implement here the way `Projection` needs one.
745 const TAG_KEY: &'static str;
746
747 /// Which tag key names this snapshot's "owner" dimension, if it has
748 /// one and it differs from `TAG_KEY` itself - `None` (the default)
749 /// for a snapshot with no such notion. Deliberately Rust-only, no
750 /// spec entity field and no registration surface, the identical
751 /// treatment `TAG_KEY` itself already gets (`Snapshot` is compiled,
752 /// deployed configuration throughout - see this trait's own doc
753 /// comment). When set, each stored row's own owner value is derived
754 /// at fold time from whichever event touched it: the value of the
755 /// tag on `event.tags` whose key matches this one, if present - not
756 /// necessarily the same tag `TAG_KEY` itself reads (a snapshot keyed
757 /// by `"account"` might still need owner-scoping by `"company"`).
758 /// `inspectSnapshot` rejects a `scope`-restricted `RoleAccessMapping`
759 /// whose `scope` does not match a stored row's own derived owner -
760 /// see `access_control::scope_matches_owner` and
761 /// `snapshot_query::inspect_snapshot_field`'s own doc comment. Cross-
762 /// tenant read fix (docs/architecture.md's own write-up of these
763 /// passes).
764 const OWNER_TAG_KEY: Option<&'static str> = None;
765
766 /// Bumped by hand whenever `fold()`'s own logic or `State`'s shape
767 /// changes - a stored row at an older version is treated as if it
768 /// doesn't exist (see `CommandType::decide_from_snapshot`'s own doc
769 /// comment), never trusted. Not inferred: Rust has no way to detect
770 /// a fold's own semantic change, only a decider declaring one can.
771 const VERSION: u64;
772
773 /// How many partitions this snapshot's own tag values hash into for
774 /// catch-up (issue #25, docs/architecture.md §52 - the
775 /// `Snapshot` twin of `Projection::PARTITION_COUNT`, see that
776 /// const's own doc comment for the full reasoning, identical here
777 /// with the derived tag *value* playing the role `Projection`'s own
778 /// `keys()` return values play). `1` (the default) means every tag
779 /// value hashes to the same partition, so `catch_up_snapshots` takes
780 /// its original, unpartitioned code path unchanged.
781 const PARTITION_COUNT: u32 = 1;
782
783 /// Folds one event into `state`, in place - the identical shape
784 /// `Projection::project` already has, minus the `key` parameter.
785 /// Implement this, or [`fold_for`](Snapshot::fold_for) when the fold
786 /// depends on which tag value it's folding for; the framework only
787 /// calls `fold_for`, whose default calls this. The default here
788 /// changes nothing.
789 fn fold(_state: &mut Self::State, _event: &Self::Event) {}
790
791 /// Folds `event` into the state for `tag_value`, one of the values of
792 /// `TAG_KEY` the event carries (docs/architecture.md §202). An event
793 /// carrying several - a transfer tagged with both accounts - is
794 /// folded into each value's state, once per value, so a fold that
795 /// treats them differently (a debit for one, a credit for the other)
796 /// tells them apart by `tag_value`. The default calls `fold()`.
797 fn fold_for(state: &mut Self::State, tag_value: &str, event: &Self::Event) {
798 let _ = tag_value;
799 Self::fold(state, event)
800 }
801}
802
803/// One blind-index constraint (docs/architecture.md §205): the tag key
804/// whose values are stored as a keyed hash of a payload field instead of
805/// the field, so a command can check that no earlier event carries the
806/// same value - an email address registered once - without the value
807/// ever being stored in the clear. A type tags it with
808/// [`TagMapping::blind::<Self>`](crate::shared::TagMapping::blind), which
809/// may name a sensitive field; a command's consistency query reads it
810/// like any other tag.
811///
812/// ```
813/// use skilj_core::blind::normalize;
814/// use skilj_core::plugin::{BlindErasure, BlindTag};
815///
816/// struct CustomerEmail;
817/// impl BlindTag for CustomerEmail {
818/// const BOUNDED_CONTEXT: &'static str = "crm";
819/// const TAG_KEY: &'static str = "email";
820/// const ERASURE: BlindErasure = BlindErasure::with_subject("customer");
821/// fn normalize(value: &str) -> String {
822/// normalize::email(value)
823/// }
824/// }
825/// ```
826///
827/// Every instance serving the bounded context must declare it, with the
828/// same `blind_index_key`: an instance that doesn't refuses to derive the
829/// tag (`blind_tag_not_declared`) rather than store the value.
830pub trait BlindTag {
831 /// See `CommandType::BOUNDED_CONTEXT`'s own doc comment.
832 const BOUNDED_CONTEXT: &'static str = DEFAULT_BOUNDED_CONTEXT;
833
834 /// The tag key. One `BlindTag` per key per bounded context; the key's
835 /// constraint is its own HMAC key, derived from the
836 /// `blind_index_key` and `(BOUNDED_CONTEXT, TAG_KEY)`.
837 const TAG_KEY: &'static str;
838
839 /// Bumped by hand whenever [`normalize`](BlindTag::normalize)
840 /// changes what it returns for any input. Values hashed under the old
841 /// normalizer don't match new ones, so `build()` refuses a version
842 /// that differs from the stored one rather than silently letting a
843 /// taken value be taken again.
844 const NORMALIZER_VERSION: u32 = 1;
845
846 /// What `forgetSubject` does with the tag - see [`BlindErasure`].
847 const ERASURE: BlindErasure;
848
849 /// What a payload value becomes before it's hashed, so values that
850 /// mean the same thing collide: [`crate::blind::normalize::email`]
851 /// trims and lowercases. Must be deterministic. It can also add an
852 /// application secret of its own - a pepper the database never sees.
853 /// The default leaves the value as it is.
854 fn normalize(value: &str) -> String {
855 value.to_string()
856 }
857}
858
859/// What `forgetSubject` does with a [`BlindTag`]'s hashes.
860#[derive(Debug, Clone, Copy, PartialEq, Eq)]
861#[non_exhaustive]
862pub enum BlindErasure {
863 /// The hash stays: the value is still taken after the subject is
864 /// forgotten. For a rule that outlives the person - a trial used once,
865 /// an address that was banned.
866 Keep,
867 /// Forgetting `subject_key`'s subject removes the tag from every event
868 /// and command whose `subject_tag` tag carries the subject's value, so
869 /// the value can be taken again and no hash of it is left. Every type
870 /// tagging the blind key must also tag `subject_tag`; `build()`
871 /// checks.
872 WithSubject {
873 /// The `SensitiveField` subject key whose forgetting removes the tag.
874 subject_key: &'static str,
875 /// The plain tag carrying the subject's value on every event and
876 /// command that carries the blind tag.
877 subject_tag: &'static str,
878 },
879}
880
881impl BlindErasure {
882 /// [`BlindErasure::WithSubject`] where the encryption subject key and
883 /// the plain tag carrying the subject's value share one name - the
884 /// usual case, e.g. `SensitiveField::new("email", "customer",
885 /// "customer_id")` next to `TagMapping::new("customer", "customer_id")`.
886 pub const fn with_subject(subject: &'static str) -> Self {
887 BlindErasure::WithSubject {
888 subject_key: subject,
889 subject_tag: subject,
890 }
891 }
892}
893
894impl From<BlindErasure> for crate::blind::Erasure {
895 fn from(erasure: BlindErasure) -> Self {
896 match erasure {
897 BlindErasure::Keep => crate::blind::Erasure::Keep,
898 BlindErasure::WithSubject {
899 subject_key,
900 subject_tag,
901 } => crate::blind::Erasure::WithSubject {
902 subject_key: subject_key.to_string(),
903 subject_tag: subject_tag.to_string(),
904 },
905 }
906 }
907}
908
909/// One snapshot's state for one tag value, as a decision is handed it
910/// (docs/architecture.md §202): the stored row with every later event
911/// carrying the value folded in, JSON-encoded. Read typed through
912/// [`Decision::snapshot`].
913#[derive(Debug, Clone, PartialEq, Eq)]
914pub struct SnapshotState {
915 /// `Snapshot::NAME`.
916 pub snapshot: String,
917 /// The tag value this state was folded for.
918 pub tag_value: String,
919 /// The folded state, JSON-encoded.
920 pub state_json: String,
921 /// The position `state_json` holds every event carrying the value
922 /// through: the stored row's `as_of_sequence`, or the last event
923 /// folded on top of it; -1 for none.
924 pub folded_through: i64,
925}
926
927/// The time a decision is made against, and, for a command a deadline
928/// fired, that deadline's own schedule and expiry (issue #97).
929///
930/// `decide()` never reads a clock itself (`CommandType`'s own doc comment,
931/// §1.1): the framework stamps every submission with the `now` it uses for
932/// the command's own `created_at`, its idempotency row and the events it
933/// appends, and hands that same instant here. A decision therefore stays
934/// deterministic and replayable, and a test can drive it with any `now`.
935/// `decide_with()` reads it through [`Decision::context`] (`Decision::now`
936/// and `Decision::deadline` are the two shorthands); the legacy
937/// `decide_from_snapshot` is handed it directly.
938#[derive(Debug, Clone, PartialEq, Eq)]
939pub struct DecisionContext {
940 /// The instant the submission is stamped with - the same `now` the
941 /// command's own `created_at` and idempotency row carry.
942 pub now: chrono::DateTime<chrono::Utc>,
943 /// Present only when this submission is a deadline firing
944 /// (`db::fire_due_deadlines`); `None` for every ordinary submission.
945 pub deadline: Option<DeadlineContext>,
946}
947
948impl DecisionContext {
949 /// An ordinary submission's context: `now`, no deadline.
950 pub fn at(now: chrono::DateTime<chrono::Utc>) -> Self {
951 Self {
952 now,
953 deadline: None,
954 }
955 }
956
957 /// A deadline firing's context (issue #97): `scheduled_at` is
958 /// the fired row's own `fire_at`, `deadline_at` its optional
959 /// `expires_at`.
960 pub fn for_deadline(
961 now: chrono::DateTime<chrono::Utc>,
962 scheduled_at: chrono::DateTime<chrono::Utc>,
963 deadline_at: Option<chrono::DateTime<chrono::Utc>>,
964 ) -> Self {
965 Self {
966 now,
967 deadline: Some(DeadlineContext::new(scheduled_at, deadline_at, now)),
968 }
969 }
970}
971
972/// What a deadline-fired command's `decide()` may reason about (
973/// issue #97): `scheduled_at` is the deadline's own `fire_at` (when the
974/// chain intended it to fire), `deadline_at` its optional `expires_at`,
975/// and `expired` whether this firing happened after `deadline_at`. A
976/// target command can refuse an action it fired too late for, and can tell
977/// a genuine firing from a redrive or a retry by comparing
978/// `DecisionContext::now` with `scheduled_at`.
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980pub struct DeadlineContext {
981 /// The fired row's own `fire_at` - when the deadline was scheduled to
982 /// fire.
983 pub scheduled_at: chrono::DateTime<chrono::Utc>,
984 /// The fired row's own `expires_at`, if it had one.
985 pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
986 /// `deadline_at` is `Some` and `now` is past it. Always `false` when
987 /// there is no expiry at all.
988 pub expired: bool,
989}
990
991impl DeadlineContext {
992 /// A deadline's context: `expired` is whether `now` is past `deadline_at`.
993 pub fn new(
994 scheduled_at: chrono::DateTime<chrono::Utc>,
995 deadline_at: Option<chrono::DateTime<chrono::Utc>>,
996 now: chrono::DateTime<chrono::Utc>,
997 ) -> Self {
998 Self {
999 scheduled_at,
1000 deadline_at,
1001 expired: deadline_at.is_some_and(|at| now > at),
1002 }
1003 }
1004}
1005
1006/// What `CommandType::decide_with` decides from (docs/architecture.md
1007/// §202): the events the command's query selects, the state of every
1008/// snapshot item it derives, and the [`DecisionContext`] the decision is
1009/// made against (issue #97). Built by the framework; room to grow
1010/// without changing `decide_with`'s signature.
1011pub struct Decision<'a, E> {
1012 events: &'a [E],
1013 snapshots: &'a [SnapshotState],
1014 context: &'a DecisionContext,
1015}
1016
1017impl<'a, E> Decision<'a, E> {
1018 /// Built by the framework for each decision.
1019 pub fn new(
1020 events: &'a [E],
1021 snapshots: &'a [SnapshotState],
1022 context: &'a DecisionContext,
1023 ) -> Self {
1024 Self {
1025 events,
1026 snapshots,
1027 context,
1028 }
1029 }
1030
1031 /// The events the query's items other than snapshot items select, in
1032 /// sequence order, each once - `decide()`'s `matching_events`.
1033 pub fn events(&self) -> &'a [E] {
1034 self.events
1035 }
1036
1037 /// Snapshot `S`'s state for `tag_value`, as of the command's read,
1038 /// with every event carrying the value folded in. `None` when no item
1039 /// of the query derived that value for `S`: a decision should treat
1040 /// that as a misconfiguration rather than an empty state.
1041 pub fn snapshot<S: Snapshot>(&self, tag_value: &str) -> Option<S::State> {
1042 self.snapshots
1043 .iter()
1044 .find(|s| s.snapshot == S::NAME && s.tag_value == tag_value)
1045 .and_then(|s| serde_json::from_str(&s.state_json).ok())
1046 }
1047
1048 /// The time this decision is made against, and, for a deadline firing,
1049 /// the deadline's own context (issue #97).
1050 pub fn context(&self) -> &'a DecisionContext {
1051 self.context
1052 }
1053
1054 /// Shorthand for `self.context().now` - the instant the submission is
1055 /// stamped with, so `decide()` can reason about time without reading a
1056 /// clock.
1057 pub fn now(&self) -> chrono::DateTime<chrono::Utc> {
1058 self.context.now
1059 }
1060
1061 /// Shorthand for `self.context().deadline`: the deadline that fired
1062 /// this command, when one did (issue #97).
1063 pub fn deadline(&self) -> Option<&'a DeadlineContext> {
1064 self.context.deadline.as_ref()
1065 }
1066}
1067
1068/// Type-erased dispatch to a bounded context's own typed `decide()` -
1069/// what `CommandTrigger`'s REST handler and GraphQL's `CommandSubmission`
1070/// resolver both call through to actually process a command. Lives here,
1071/// as a trait, rather than `skilj-rest`/
1072/// `skilj-graphql` reaching into whichever crate happens to build the
1073/// concrete registry (`skilj`'s `SkiljBuilder`, today): both surface
1074/// crates already depend on `skilj-core`, so this is the natural shared
1075/// boundary - the same "opaque handle both sides can reach" reasoning
1076/// `db::Pool` already uses. See docs/architecture.md §1.7/[§8](../../../docs/architecture.md#open-for-a-future-pass) item 4.
1077///
1078/// A consumer using `skilj-core` + `skilj-rest` directly, without the
1079/// `skilj` facade's builder, implements this by hand instead - nothing
1080/// about it is facade-specific.
1081pub trait CommandDispatcher: Send + Sync {
1082 /// `None` when no `(bounded_context, command_type)` pair matches
1083 /// anything registered - the caller decides what that means for its
1084 /// own wire contract (an unrecognised-command rejection, a 404,
1085 /// whatever fits), since neither existing wire contract has needed
1086 /// to yet and this trait shouldn't invent one on their behalf.
1087 fn dispatch(
1088 &self,
1089 bounded_context: &str,
1090 command_type: &str,
1091 payload: &str,
1092 matching_events: &[Event],
1093 ) -> Option<crate::error::Result<CommandDecision>>;
1094
1095 /// `dispatch`, with the states of the query's snapshot items
1096 /// (docs/architecture.md §202) and the [`DecisionContext`] the
1097 /// decision is made against (issue #97) - what the command
1098 /// path calls. The default ignores `snapshots` and `context`, calling
1099 /// `dispatch`, right for a dispatcher whose command types declare no
1100 /// snapshot items and don't read time.
1101 fn dispatch_with(
1102 &self,
1103 bounded_context: &str,
1104 command_type: &str,
1105 payload: &str,
1106 matching_events: &[Event],
1107 snapshots: &[SnapshotState],
1108 context: &DecisionContext,
1109 ) -> Option<crate::error::Result<CommandDecision>> {
1110 let _ = (snapshots, context);
1111 self.dispatch(bounded_context, command_type, payload, matching_events)
1112 }
1113
1114 /// The snapshots this dispatcher's command types name in
1115 /// [`QueryItemMapping::snapshot`] items (docs/architecture.md §202):
1116 /// what the command path reads their versions, defaults and folds
1117 /// from, before the lock and under it. `None`, the default, for a
1118 /// dispatcher whose command types declare none; a command whose query
1119 /// has one is then refused as a configuration error.
1120 fn snapshots(&self) -> Option<&dyn SnapshotDispatcher> {
1121 None
1122 }
1123
1124 /// The registered command type's own `CommandType::required_role()` -
1125 /// see that method's own doc comment. Outer `None` mirrors
1126 /// `dispatch`'s "pair isn't registered at all" convention; inner
1127 /// `None` is a registered command type declaring no extra gate (the
1128 /// default). Meant to be checked by `skilj-graphql`'s mutation
1129 /// resolver *before* calling `dispatch` at all, so an unauthorised
1130 /// caller never reaches `decide()` - not folded into `dispatch`
1131 /// itself, since REST triggering never needs this check (`CommandToken`
1132 /// is its own, separate per-token grant) and shouldn't pay for it.
1133 fn required_role(
1134 &self,
1135 bounded_context: &str,
1136 command_type: &str,
1137 ) -> Option<Option<&'static str>>;
1138
1139 /// The registered command type's own `CommandType::consistency_query()`
1140 /// (docs/architecture.md §198) - `None` when the pair isn't
1141 /// registered, or for a dispatcher that predates queries; either reads
1142 /// as the default query.
1143 fn consistency_query(
1144 &self,
1145 _bounded_context: &str,
1146 _command_type: &str,
1147 ) -> Option<Vec<QueryItemMapping>> {
1148 None
1149 }
1150
1151 /// The registered command type's own `CommandType::snapshot()` -
1152 /// same outer/inner `Option` convention as `required_role`. Meant to
1153 /// be checked before `dispatch`, the same way `required_role` is:
1154 /// if this is `Some(Some(name))` and the command's own derived tags
1155 /// match `SnapshotDispatcher::tag_key(bc, name)` exactly, the caller
1156 /// should read that snapshot and call `dispatch_from_snapshot`
1157 /// instead of `dispatch` - see [docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events).
1158 fn snapshot_name(
1159 &self,
1160 bounded_context: &str,
1161 command_type: &str,
1162 ) -> Option<Option<&'static str>>;
1163
1164 /// `CommandType::decide_from_snapshot`'s own type-erased entry
1165 /// point - `dispatch`'s counterpart for the snapshot-accelerated
1166 /// path. `None` for the identical "pair isn't registered at all"
1167 /// case `dispatch` already has.
1168 fn dispatch_from_snapshot(
1169 &self,
1170 bounded_context: &str,
1171 command_type: &str,
1172 payload: &str,
1173 snapshot_state_json: &str,
1174 events_since_snapshot: &[Event],
1175 context: &DecisionContext,
1176 ) -> Option<crate::error::Result<CommandDecision>>;
1177
1178 /// The blind tags this process declares, keyed for hashing
1179 /// (docs/architecture.md §205) - what every tag derivation on the
1180 /// command path, and event creation through the same surfaces, hashes
1181 /// a blind mapping with. Empty, the default, for a dispatcher whose
1182 /// bounded contexts declare none: a stored blind mapping is then
1183 /// refused with `blind_tag_not_declared`, never derived in the clear.
1184 fn blind_index(&self) -> crate::blind::BlindIndex {
1185 crate::blind::BlindIndex::default()
1186 }
1187}
1188
1189/// Type-erased dispatch to a bounded context's own typed `project()` -
1190/// `CommandDispatcher`'s own counterpart for the other black box §1.1
1191/// draws the pure-function line around. What the new transactional
1192/// `db::insert_event_and_update_sync_projections` calls through to
1193/// actually fold an event into a `sync` projection's stored state - see
1194/// docs/architecture.md's own write-up of this pass ([§8](../../../docs/architecture.md#open-for-a-future-pass) item 6).
1195///
1196/// Async (`sync: false`, the default) projections are folded by the
1197/// background consumer `db::catch_up_bounded_context` - see its own doc
1198/// comment and docs/architecture.md's write-up of that pass. Same
1199/// `project()` call either way; the consumer doesn't know or care whether
1200/// its caller is a request (the sync path) or a poll tick (this one).
1201pub trait ProjectionDispatcher: Send + Sync {
1202 /// `None` when no `(bounded_context, projection_name)` pair matches
1203 /// anything registered - the same "pair isn't registered at all"
1204 /// convention `dispatch`/`project` below already use. `Some(vec![])`
1205 /// when the pair *is* registered but `event`'s own type isn't one
1206 /// this projection actually consumes (`Projection::keys` is never
1207 /// called for it) - `caught_up_to` still advances, no instance is
1208 /// touched, the identical "position always advances, state only
1209 /// changes when consumed" treatment this trait's own `project`
1210 /// already documents. Otherwise, `Projection::keys(event)` verbatim -
1211 /// every instance this one event updates, one call to `project`
1212 /// below per key.
1213 fn keys(
1214 &self,
1215 bounded_context: &str,
1216 projection_name: &str,
1217 event: &Event,
1218 ) -> Option<Vec<String>>;
1219
1220 /// `None` when no `(bounded_context, projection_name)` pair matches
1221 /// anything registered - the same "pair isn't registered at all"
1222 /// convention `CommandDispatcher::dispatch` uses. `Some(Ok(new_state))`
1223 /// is the projection's `state_json` after folding `event` into it,
1224 /// for the one instance named by `key` (one of `keys`'s own return
1225 /// values) - `BoundedContextEvent::try_from_event` succeeding is not
1226 /// by itself permission to call `project()`; the caller only reaches
1227 /// this once per key `keys` returned.
1228 fn project(
1229 &self,
1230 bounded_context: &str,
1231 projection_name: &str,
1232 state_json: &str,
1233 event: &Event,
1234 key: &str,
1235 ) -> Option<crate::error::Result<String>>;
1236
1237 /// `T::State::default()`, JSON-serialised - `None` for the identical
1238 /// "pair isn't registered at all" case `project`/`dispatch` already
1239 /// have. The only source `db::catch_up_bounded_context` has for a
1240 /// `ProjectionRebuild`'s own starting state when it begins (or
1241 /// resumes after a restage) folding one: a rebuild's `caught_up_to`
1242 /// resets to `None` on every `db::upsert_projection_rebuild` call,
1243 /// including a restage of an already-`building` row (see that
1244 /// function's own doc comment), so whatever state a previous attempt
1245 /// had accumulated is stale the moment that happens - only the
1246 /// dispatcher, not whichever caller triggered the reset, knows the
1247 /// *current* correct starting point.
1248 fn default_state(&self, bounded_context: &str, projection_name: &str) -> Option<String>;
1249
1250 /// The registered projection's own `Projection::OWNER_TAG_KEY` -
1251 /// `CommandDispatcher::snapshot_name`'s identical `Option<Option<_>>`
1252 /// shape: outer `None` for the same "pair isn't registered at all"
1253 /// case every method here already has, inner `None` when the
1254 /// projection is registered but declares no owner dimension (the
1255 /// default, and every projection that predates this).
1256 fn owner_tag_key(
1257 &self,
1258 bounded_context: &str,
1259 projection_name: &str,
1260 ) -> Option<Option<&'static str>>;
1261
1262 /// The registered projection's own `Projection::REQUIRES_CAPABILITY` - the
1263 /// identical `Option<Option<_>>` shape `owner_tag_key` just above
1264 /// already has, for the same reason. Defaulted to `None` (unlike
1265 /// `owner_tag_key`, which has no default) - every call site here
1266 /// only ever `.flatten()`s the result, for which the outer "pair
1267 /// isn't registered" `None` and the inner "registered, no capability
1268 /// required" `Some(None)` already read identically, so a dispatcher
1269 /// with nothing capability-gated needs no explicit override at all.
1270 fn requires_capability(
1271 &self,
1272 _bounded_context: &str,
1273 _projection_name: &str,
1274 ) -> Option<Option<&'static str>> {
1275 None
1276 }
1277
1278 /// The registered projection's own `Projection::PARTITION_COUNT`
1279 /// (issue #25, docs/architecture.md §51) - `None` for the
1280 /// same "pair isn't registered at all" case every method here
1281 /// already has, treated by every real caller (`db::
1282 /// catch_up_bounded_context`) identically to `Some(1)`: unpartitioned.
1283 /// Defaulted (unlike `owner_tag_key`, which has no default) for the
1284 /// same reason `requires_capability` is - a dispatcher with nothing partitioned
1285 /// needs no explicit override at all.
1286 fn partition_count(&self, _bounded_context: &str, _projection_name: &str) -> Option<u32> {
1287 None
1288 }
1289}
1290
1291/// Type-erased dispatch to a bounded context's own typed `Snapshot::fold` -
1292/// `CommandDispatcher`'s `dispatch_from_snapshot`/`snapshot_name`'s own
1293/// counterpart for the other half of the type-erasure boundary, and what
1294/// `db::catch_up_snapshots` (the background catch-up task -
1295/// [docs/architecture.md §19](../../../docs/architecture.md#optional-snapshotting-matching-events)) calls through to actually fold an event
1296/// into a stored snapshot row. Same "outer `None` = not registered"
1297/// convention every other dispatcher trait in this module already uses.
1298pub trait SnapshotDispatcher: Send + Sync {
1299 /// Every `Snapshot::NAME` registered for `bounded_context` in this
1300 /// process - `db::catch_up_snapshots`' own discovery mechanism,
1301 /// since (deliberately - see `Snapshot`'s own doc comment) there is
1302 /// no `snapshots` metadata table to enumerate the way
1303 /// `list_projections_for_bounded_context` does for `Projection`.
1304 fn snapshot_names(&self, bounded_context: &str) -> Vec<&'static str>;
1305
1306 /// The registered snapshot's own `Snapshot::TAG_KEY`.
1307 fn tag_key(&self, bounded_context: &str, snapshot_name: &str) -> Option<&'static str>;
1308
1309 /// The registered snapshot's own `Snapshot::OWNER_TAG_KEY` -
1310 /// `ProjectionDispatcher::owner_tag_key`'s identical `Option<Option<_>>`
1311 /// shape: outer `None` for the same "pair isn't registered at all"
1312 /// case every method here already has, inner `None` when the
1313 /// snapshot is registered but declares no owner dimension (the
1314 /// default, and every snapshot that predates this).
1315 fn owner_tag_key(
1316 &self,
1317 bounded_context: &str,
1318 snapshot_name: &str,
1319 ) -> Option<Option<&'static str>>;
1320
1321 /// The registered snapshot's own `Snapshot::VERSION` - compared
1322 /// against a stored row's `snapshot_version` to decide whether it's
1323 /// still trustworthy (see `CommandType::decide_from_snapshot`'s own
1324 /// doc comment).
1325 fn version(&self, bounded_context: &str, snapshot_name: &str) -> Option<u64>;
1326
1327 /// `Some(Ok(new_state))` is `state_json` with `event` folded into it
1328 /// via `Snapshot::fold` - `None` for the identical "pair isn't
1329 /// registered at all" case every dispatcher here already has.
1330 /// `event`'s own `BoundedContextEvent::try_from_event` failing is a
1331 /// real `Err`, not a silent skip - a stored event this snapshot's
1332 /// own `TAG_KEY` names should always convert cleanly.
1333 fn fold(
1334 &self,
1335 bounded_context: &str,
1336 snapshot_name: &str,
1337 state_json: &str,
1338 event: &Event,
1339 ) -> Option<crate::error::Result<String>>;
1340
1341 /// `fold`, for one of the values of the snapshot's tag key `event`
1342 /// carries: `Snapshot::fold_for` (docs/architecture.md §202). What
1343 /// catch-up and the command path call. The default ignores
1344 /// `tag_value` and calls `fold`.
1345 fn fold_value(
1346 &self,
1347 bounded_context: &str,
1348 snapshot_name: &str,
1349 tag_value: &str,
1350 state_json: &str,
1351 event: &Event,
1352 ) -> Option<crate::error::Result<String>> {
1353 let _ = tag_value;
1354 self.fold(bounded_context, snapshot_name, state_json, event)
1355 }
1356
1357 /// `T::State::default()`, JSON-serialised - the starting point for a
1358 /// tag value with no stored snapshot yet, or one whose stored
1359 /// `snapshot_version` no longer matches. `None` for the identical
1360 /// "pair isn't registered at all" case.
1361 fn default_state(&self, bounded_context: &str, snapshot_name: &str) -> Option<String>;
1362
1363 /// The registered snapshot's own `Snapshot::PARTITION_COUNT`
1364 /// (issue #25, docs/architecture.md §52) -
1365 /// `ProjectionDispatcher::partition_count`'s identical shape and
1366 /// defaulting: `None` for the same "pair isn't registered at all"
1367 /// case every method here already has, treated by every real caller
1368 /// (`db::catch_up_snapshots`) identically to `Some(1)`: unpartitioned.
1369 fn partition_count(&self, _bounded_context: &str, _snapshot_name: &str) -> Option<u32> {
1370 None
1371 }
1372}
1373
1374/// A single-hop, stateless reaction: when `Source` commits in its own
1375/// bounded context, submit `Target`'s own command into *its* bounded
1376/// context - the answer to "make messages cross bounded contexts
1377/// without needing an external system like Temporal" (docs/architecture.md's
1378/// own write-up of this pass) for the case that never needed Temporal's
1379/// own durable-timer/retry/compensation machinery in the first place:
1380/// both bounded contexts already live in the same Postgres database,
1381/// often the same process, so relaying one committed fact into another
1382/// bounded context's own command needs nothing more than a durable
1383/// cursor and the same `submit_command` path every other caller already
1384/// goes through.
1385///
1386/// Deliberately **not** a Saga/process manager - see this trait's own
1387/// design note in docs/architecture.md: no multi-step state is tracked
1388/// across calls, no compensation, no retry beyond `submit_command`'s own
1389/// idempotency-key mechanism. One commit causes at most one more commit
1390/// elsewhere, the same bounded, single-hop shape `submit_command`'s own
1391/// DCB-conflict retry already has - not an open-ended state machine.
1392///
1393/// A `Target` command that gets rejected by its own `decide()` is a
1394/// legitimate business outcome here too (§5.4's own "a business
1395/// rejection is not an error" register), not a failure to retry -
1396/// `route()` translating `Source`'s payload into a `Target` payload
1397/// `Target::decide()` goes on to reject is exactly as valid an outcome
1398/// as one it accepts.
1399pub trait CrossContextRoute {
1400 /// The `EventType` this route reacts to, in its own bounded context.
1401 type Source: EventType;
1402 /// The `CommandType` this route submits, in *its* bounded context -
1403 /// deliberately a command, not a raw event: `Target`'s own
1404 /// `decide()` still gets to accept, reject, or reshape the outcome,
1405 /// the same as every other command submission. A route that skipped
1406 /// straight to creating an event would bypass whatever domain rules
1407 /// the target bounded context wants to enforce on its own history.
1408 type Target: CommandType;
1409
1410 /// This route's own stable identity - names its durable cursor row
1411 /// (`{Source::BOUNDED_CONTEXT}.cross_context_route_cursors`) and the
1412 /// idempotency key derived for every command it submits
1413 /// (`"{NAME}:{source_event_sequence}"`), so a redelivered/retried
1414 /// catch-up tick is exactly as safe as any other idempotency-keyed
1415 /// submission already is.
1416 const NAME: &'static str;
1417
1418 /// Where this route's own durable cursor starts the first time it is
1419 /// ever registered - `Beginning` (the default) replays every
1420 /// `Source` occurrence ever committed, exactly what every route
1421 /// registered before this const existed already does. A route whose
1422 /// job is a side effect that must never re-fire for old history (the
1423 /// canonical case: `UserRegistered -> SendWelcomeEmail`) overrides
1424 /// this to `Latest`, the `CrossContextRoute`-side counterpart to
1425 /// `EventReadStartPosition::Latest` on the spec-backed `EventReadToken`
1426 /// track (docs/architecture.md's own write-up of this pass) - a
1427 /// separate Rust-only type rather than reusing that one, since a
1428 /// route has no spec entity of its own to hang a shared type off of
1429 /// (§36's own "no spec entity" note). See
1430 /// `db::catch_up_cross_context_route`'s own doc comment for exactly
1431 /// when this is read - only on this route's first-ever catch-up
1432 /// tick, never again once a cursor row exists.
1433 const START_FROM: CrossContextRouteStartFrom = CrossContextRouteStartFrom::Beginning;
1434
1435 /// `None` skips this occurrence of `Source` entirely - no command
1436 /// submitted, cursor still advances (retrying can never produce a
1437 /// target payload for an occurrence this route itself decided
1438 /// doesn't apply). `Some(payload)` submits `Target` with that
1439 /// payload, going through the identical `decide()`/`submit_command`
1440 /// path every other caller of `Target` already does.
1441 fn route(
1442 source_payload: &<Self::Source as EventType>::Payload,
1443 ) -> Option<<Self::Target as CommandType>::Payload>;
1444}
1445
1446/// See `CrossContextRoute::START_FROM`. Deliberately not
1447/// `#[derive(Default)]`: `Beginning` is the trait const's own default,
1448/// spelled out explicitly at every use site (`CrossContextRouteInfo::
1449/// start_from`) rather than leaned on implicitly, so a reader never has
1450/// to check whether "the default" here means this type's `Default` impl
1451/// or the trait const's - there is only ever one to check.
1452///
1453/// `AtSequence`/`AtTime` are this trait's own counterpart to
1454/// `EventReadStartPosition`'s identically-named extension
1455/// (docs/architecture.md's own write-up of this pass) - a route's first
1456/// tick seeds its cursor at a caller-chosen sequence, or at the highest
1457/// sequence already committed at or before a given point in time,
1458/// exactly like an `EventReadToken` minted the same way would. `AtTime`
1459/// carries a plain Unix timestamp (seconds, UTC) rather than
1460/// `chrono::DateTime<Utc>` for one structural reason: `START_FROM` is a
1461/// trait *associated const*, so every value it can ever hold has to be
1462/// const-evaluable at the implementing type's own definition site, and
1463/// `DateTime<Utc>` has no `const fn` constructor to build one from - an
1464/// `i64` literal is. Converted to a real `DateTime<Utc>` only where it's
1465/// actually compared against event timestamps
1466/// (`db::catch_up_cross_context_route`).
1467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1468#[non_exhaustive]
1469pub enum CrossContextRouteStartFrom {
1470 /// From the first event in the source bounded context.
1471 Beginning,
1472 /// From the events committed after the route first starts; earlier ones
1473 /// are skipped.
1474 Latest,
1475 /// From the event after this sequence number.
1476 AtSequence(i64),
1477 /// Unix timestamp, seconds, UTC - see this enum's own doc comment
1478 /// for why not `DateTime<Utc>`.
1479 AtTime(i64),
1480}
1481
1482/// One registered route's own static identity - `CrossContextRouteDispatcher::routes()`'s
1483/// own element type, enumerated once at startup by the background
1484/// catch-up loop (`db::catch_up_cross_context_route`).
1485#[derive(Debug, Clone, Copy)]
1486pub struct CrossContextRouteInfo {
1487 /// `CrossContextRoute::NAME`: the route's identity and the name of its
1488 /// cursor.
1489 pub name: &'static str,
1490 /// The bounded context the source event type lives in.
1491 pub source_bounded_context: &'static str,
1492 /// The event type whose events the route reacts to.
1493 pub source_event_type: &'static str,
1494 /// The bounded context the target command type lives in.
1495 pub target_bounded_context: &'static str,
1496 /// The command type the route submits.
1497 pub target_command_type: &'static str,
1498 /// `CrossContextRoute::START_FROM`, carried through so
1499 /// `db::catch_up_cross_context_route` can act on it without needing
1500 /// the route's own concrete type.
1501 pub start_from: CrossContextRouteStartFrom,
1502}
1503
1504/// Type-erased dispatch to a bounded context's own typed
1505/// `CrossContextRoute::route` - the same "outer `None` = not registered
1506/// at all" convention every other dispatcher trait in this module
1507/// already uses, one level deeper here since `route()` itself can also
1508/// legitimately produce nothing for a given occurrence.
1509pub trait CrossContextRouteDispatcher: Send + Sync {
1510 /// Every registered route, across every bounded context - routes
1511 /// aren't scoped to one bounded context the way `EventType`/
1512 /// `CommandType`/`Projection` are (a route by definition spans two),
1513 /// so unlike `ProjectionDispatcher::keys` and friends there is no
1514 /// `bounded_context` parameter to filter by here; the background
1515 /// catch-up loop calls this once and iterates the full list itself.
1516 fn routes(&self) -> Vec<CrossContextRouteInfo>;
1517
1518 /// `route_name` names one of `routes()`'s own entries;
1519 /// `source_payload_json` is the triggering `Source` event's own
1520 /// stored payload. Outer `None` - `route_name` isn't registered at
1521 /// all (defensive; the caller only ever names one of its own
1522 /// `routes()`). `Some(Err(e))` - the stored payload didn't
1523 /// deserialize into `Source::Payload` (see `BoundedContextEvent::
1524 /// try_from_event`'s own doc comment on why this is still reachable
1525 /// even though payloads are schema-checked at write time). `Some(Ok(None))` -
1526 /// `route()` itself decided this occurrence doesn't apply. `Some(Ok(Some(payload)))` -
1527 /// the `Target` command's own JSON payload, ready to submit.
1528 fn route(
1529 &self,
1530 route_name: &str,
1531 source_payload_json: &str,
1532 ) -> Option<Result<Option<String>, serde_json::Error>>;
1533}
1534
1535/// issue #20's own "where does this cursor start" const, one per
1536/// `ScheduleDeadline`/`CancelDeadline` implementor - identical in shape to
1537/// `CrossContextRouteStartFrom` but its own type rather than a reuse of
1538/// it: a deadline reactor isn't a route, and giving it a name that says
1539/// what it's actually for beats leaning on a type whose own doc comment
1540/// is written entirely in terms of routes. See `CrossContextRouteStartFrom`'s
1541/// own doc comment for why `AtTime` carries a plain Unix timestamp rather
1542/// than `DateTime<Utc>` - the identical reasoning (a trait associated
1543/// const has to be const-evaluable) applies here.
1544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1545#[non_exhaustive]
1546pub enum DeadlinePollStartFrom {
1547 /// From the first event in the source bounded context.
1548 Beginning,
1549 /// From the events committed after the reactor first starts; earlier ones
1550 /// are skipped.
1551 Latest,
1552 /// From the event after this sequence number.
1553 AtSequence(i64),
1554 /// Unix timestamp, seconds, UTC.
1555 AtTime(i64),
1556}
1557/// Missed-run policy for recurring deadlines (issue #95).
1558///
1559/// Mirrors the global scheduler's own `MissedOccurrencePolicy`
1560/// (`ReplayBacklog`/`FireOnce`/`Skip`) but is deliberately its own type:
1561/// a global schedule and a per-entity deadline are different reactor
1562/// families (`SkipMissedOccurrences` skips on the shared `event_types`
1563/// position; this one advances a private `deadlines` chain), and a name
1564/// saying what it's for beats leaning on a type whose own doc comment is
1565/// written entirely in terms of `event_types`. Same ordering the global
1566/// one uses: `Skip` never replays, `FireOnce` collapses a backlog to one
1567/// firing, `FireAll` steps through every missed occurrence.
1568#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1569#[non_exhaustive]
1570pub enum MissedRunPolicy {
1571 /// Missed occurrences are not fired; the chain resumes at the next
1572 /// occurrence still ahead.
1573 Skip,
1574 /// A backlog of missed occurrences fires once, then the chain resumes
1575 /// ahead.
1576 FireOnce,
1577 /// Every missed occurrence fires, in order.
1578 FireAll,
1579}
1580
1581/// Recurrence specification for per-entity deadlines (issue
1582/// #95). `schedule` is the same 7-field Quartz-dialect cron expression
1583/// (`cron` crate) the global scheduler evaluates, so one mental model
1584/// covers both surfaces. `start_at`/`end_at` bound the occurrences
1585/// themselves; `valid_until` is the kill-switch - a pending row past it
1586/// never fires and begets nothing, even if an occurrence would still be
1587/// due. All three are optional: absent means unbounded that way.
1588#[derive(Debug, Clone)]
1589pub struct RecurrenceSpec {
1590 /// A 7-field Quartz-dialect cron expression (`cron` crate) the occurrences
1591 /// follow.
1592 pub schedule: String,
1593 /// What happens to occurrences missed while nothing could fire them.
1594 pub policy: MissedRunPolicy,
1595 /// No occurrence before this instant; `None` for no lower bound.
1596 pub start_at: Option<chrono::DateTime<chrono::Utc>>,
1597 /// No occurrence after this instant; `None` for no upper bound.
1598 pub end_at: Option<chrono::DateTime<chrono::Utc>>,
1599 /// A pending firing past this instant never fires and schedules nothing
1600 /// further; `None` for no limit.
1601 pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
1602}
1603
1604/// The next firing of a recurring deadline chain, given only the
1605/// previous firing - pure, no I/O, so both `db::fire_due_deadlines` and
1606/// the unit tests below evaluate the identical math. `prev_fire_at` is
1607/// the occurrence the chain just fired (or, at schedule time, the initial
1608/// `fire_at`); `now` bounds the catch-up, the same register
1609/// `event_store::create_system_event`'s own `nothing_later_is_due`
1610/// already is. Returns `None` when the chain ends: the schedule doesn't
1611/// parse, no occurrence is left, or the candidate fails the validity
1612/// window (`start_at`/`end_at`/`valid_until`, or `now` past
1613/// `valid_until`).
1614pub fn next_recurrence_fire_at(
1615 recurrence: &RecurrenceSpec,
1616 prev_fire_at: chrono::DateTime<chrono::Utc>,
1617 now: chrono::DateTime<chrono::Utc>,
1618) -> Option<chrono::DateTime<chrono::Utc>> {
1619 use crate::event_store::{latest_occurrence_at_or_before, next_occurrence_after};
1620 if now
1621 > recurrence
1622 .valid_until
1623 .unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
1624 {
1625 return None;
1626 }
1627 let candidate = match recurrence.policy {
1628 MissedRunPolicy::Skip => {
1629 // Missed occurrences are skipped outright: the first
1630 // occurrence strictly after whichever is later, the previous
1631 // firing or now.
1632 next_occurrence_after(&recurrence.schedule, prev_fire_at.max(now))?
1633 }
1634 MissedRunPolicy::FireOnce => {
1635 // A backlog collapses to a single firing of its latest
1636 // occurrence - the `fire_once` register §150 already is for
1637 // the global scheduler - and with no backlog this is just the
1638 // next occurrence after the previous firing.
1639 match latest_occurrence_at_or_before(&recurrence.schedule, now) {
1640 Some(latest) if latest > prev_fire_at => latest,
1641 _ => next_occurrence_after(&recurrence.schedule, prev_fire_at)?,
1642 }
1643 }
1644 MissedRunPolicy::FireAll => {
1645 // Step through every occurrence, missed or not.
1646 next_occurrence_after(&recurrence.schedule, prev_fire_at)?
1647 }
1648 };
1649 if candidate
1650 < recurrence
1651 .start_at
1652 .unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
1653 {
1654 return None;
1655 }
1656 if candidate
1657 > recurrence
1658 .end_at
1659 .unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
1660 {
1661 return None;
1662 }
1663 if candidate
1664 > recurrence
1665 .valid_until
1666 .unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
1667 {
1668 return None;
1669 }
1670 Some(candidate)
1671}
1672
1673/// `ScheduleDeadline::schedule`'s own `Some` output - everything a
1674/// one-shot, per-entity timer needs: when it fires (`fire_at`), what it's
1675/// scoped to for a later `CancelDeadline` lookup (`tags`), and what to
1676/// submit when it does (`payload`, `Self::Target`'s own payload type).
1677/// `recurrence` (issue #95) turns the one-shot into the first
1678/// firing of a chain: after it fires, `db::fire_due_deadlines` derives
1679/// the next `fire_at` from the cron schedule and the missed-run policy,
1680/// until the validity window ends or a `CancelDeadline` cancels the
1681/// pending row. `None` - the default every existing schedule passes - is
1682/// the unchanged one-shot.
1683///
1684/// `expires_at` (issue #97) is an optional latest instant this
1685/// firing is still meaningful. It is carried to the fired command's
1686/// `decide_with` through [`DecisionContext::deadline`] as
1687/// `deadline_at`/`expired`, so a target that only makes sense soon after
1688/// its `fire_at` - an auto-reply, a reminder - can refuse one the
1689/// scheduler or a retry ran late. `None` (the default every existing
1690/// schedule passes) is unchanged: a firing is never `expired`. A window
1691/// probe: it does not stop the row firing, only tells the command it was
1692/// late - a chain that wants to stop outright uses
1693/// `RecurrenceSpec::valid_until`.
1694#[derive(Debug, Clone)]
1695pub struct DeadlineSpec<P> {
1696 /// When the deadline fires.
1697 pub fire_at: chrono::DateTime<chrono::Utc>,
1698 /// See this struct's own doc comment (issue #97).
1699 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1700 /// The tags a `CancelDeadline` matches to cancel this deadline before it
1701 /// fires.
1702 pub tags: Vec<Tag>,
1703 /// The target command's payload, submitted when the deadline fires.
1704 pub payload: P,
1705 /// Makes this the first firing of a recurring chain; `None` for a one-shot
1706 /// deadline.
1707 pub recurrence: Option<RecurrenceSpec>,
1708}
1709
1710/// issue #20: a native one-shot, per-entity deadline/timer -
1711/// "cancel this order if not paid within 30 minutes," without reaching
1712/// for `skilj-temporal` (docs/architecture.md §34) or hand-building it on
1713/// top of the recurring, `EventType`-wide scheduler (`rule
1714/// CreateSystemEvent`). Deliberately the same shape as `CrossContextRoute`
1715/// just above, for the same reason: `decide()`'s only output is
1716/// `CommandDecision` (`Accepted { events }` / `Rejected`) - it has no
1717/// side-effect channel to schedule a timer through, and none should be
1718/// added there. So scheduling one is itself modelled as a pure reaction
1719/// to a committed event, with a background poller doing the actual
1720/// durable write, exactly like a route reacting to one.
1721///
1722/// Fires `Target` as a real command, not a raw event - the same reasoning
1723/// `CrossContextRoute::Target` already gives: whether a deadline is still
1724/// relevant is a decision `Target::decide()` gets to make against
1725/// *current* state at fire time, not one baked in back when the timer was
1726/// scheduled.
1727pub trait ScheduleDeadline {
1728 /// The event that, on commit, may schedule a new deadline.
1729 type Source: EventType;
1730 /// The command submitted when the deadline fires.
1731 type Target: CommandType;
1732
1733 /// This schedule's own stable identity - names its durable cursor row
1734 /// and, combined with the triggering `Source` event's own sequence,
1735 /// the deterministic id of every row it inserts
1736 /// (`"{NAME}:{source_event_sequence}"`) - so a redelivered/retried
1737 /// catch-up tick re-inserts the identical row rather than a duplicate
1738 /// (`ON CONFLICT (id) DO NOTHING`), the same property a route's own
1739 /// idempotency key gives its command submissions, applied here to the
1740 /// row insert itself.
1741 const NAME: &'static str;
1742
1743 /// See `DeadlinePollStartFrom`. `Beginning` (the default) replays
1744 /// every `Source` occurrence ever committed - the same default
1745 /// `CrossContextRoute::START_FROM` has, and for the same reason: it's
1746 /// what every schedule registered before this const existed already
1747 /// does.
1748 const START_FROM: DeadlinePollStartFrom = DeadlinePollStartFrom::Beginning;
1749
1750 /// `None` skips this occurrence of `Source` entirely - no deadline
1751 /// scheduled, cursor still advances. `Some(spec)` inserts a `pending`
1752 /// row, fired the next time `spec.fire_at` comes due (or immediately,
1753 /// if it's already in the past by the time this runs - a deadline is
1754 /// a one-shot, so there's no missed-occurrence policy to choose
1755 /// between the way the recurring scheduler needs one).
1756 fn schedule(
1757 source_payload: &<Self::Source as EventType>::Payload,
1758 ) -> Option<DeadlineSpec<<Self::Target as CommandType>::Payload>>;
1759}
1760
1761/// issue #20's own cancellation half - "cancellable if the
1762/// awaited thing happens first." Bound to a specific `ScheduleDeadline`
1763/// via `Deadline` (not just any deadline sharing a tag) so two unrelated
1764/// schedules that happen to reuse a tag key never cross-cancel each
1765/// other's rows.
1766///
1767/// Cancels **by tag**, not by id: the cancelling event (say, `OrderPaid`)
1768/// only ever carries its own payload/tags, never the opaque id a
1769/// `ScheduleDeadline` implementor generated deep inside its own row
1770/// insert - tag-based lookup is what actually lets "whichever happens
1771/// first" work without threading an id back out through some side
1772/// channel. Reuses the same tag-containment query
1773/// `list_events_for_bounded_context_matching_tags` already uses for
1774/// events (docs/architecture.md §19 Problem 1), applied to the
1775/// `deadlines` table's own `tags` column.
1776///
1777/// **Adding one to an existing schedule in a rolling deploy.** A due
1778/// deadline waits until every cancel targeting its schedule has processed
1779/// its source events up to the deadline's `fire_at` - but an instance only
1780/// knows the cancels declared in its own code. While instances without
1781/// the new cancel are still running, one of them can fire a deadline whose
1782/// cancelling event had already committed but not yet been processed.
1783/// Where that matters, deploy the new cancel while no such deadline can
1784/// come due, or roll out completely before relying on it
1785/// (docs/architecture.md §164).
1786pub trait CancelDeadline {
1787 /// The event that, on commit, may cancel one or more pending
1788 /// deadlines.
1789 type Source: EventType;
1790 /// Which `ScheduleDeadline`'s own pending rows this targets.
1791 type Deadline: ScheduleDeadline;
1792
1793 /// This cancel reactor's own stable identity - names its durable
1794 /// cursor row, independent of `Self::Deadline::NAME`'s own cursor (a
1795 /// schedule and its cancel counterpart advance independently, each
1796 /// walking its own `Source` event stream).
1797 const NAME: &'static str;
1798
1799 /// See `DeadlinePollStartFrom`; same default and reasoning as
1800 /// `ScheduleDeadline::START_FROM`.
1801 const START_FROM: DeadlinePollStartFrom = DeadlinePollStartFrom::Beginning;
1802
1803 /// `None` skips this occurrence entirely - nothing cancelled, cursor
1804 /// still advances. `Some(tags)` cancels every still-`pending` row
1805 /// `Self::Deadline::NAME` owns whose own `tags` contain these -
1806 /// cancelling zero, one, or several rows is all a legitimate outcome
1807 /// here (a deadline that already fired, was already cancelled, or was
1808 /// never scheduled in the first place is not an error), the same
1809 /// register `CrossContextRoute::route` returning `None` already is.
1810 fn cancel_tags(source_payload: &<Self::Source as EventType>::Payload) -> Option<Vec<Tag>>;
1811}
1812
1813/// One registered `ScheduleDeadline`'s own static identity -
1814/// `ScheduleDeadlineDispatcher::schedules()`'s own element type, the
1815/// `ScheduleDeadline`/`CancelDeadline` pair's counterpart to
1816/// `CrossContextRouteInfo`.
1817#[derive(Debug, Clone, Copy)]
1818pub struct ScheduleDeadlineInfo {
1819 /// `ScheduleDeadline::NAME`: the schedule's identity and the name of its
1820 /// cursor.
1821 pub name: &'static str,
1822 /// The bounded context the source event type lives in.
1823 pub source_bounded_context: &'static str,
1824 /// The event type whose events schedule deadlines.
1825 pub source_event_type: &'static str,
1826 /// The bounded context the target command type lives in.
1827 pub target_bounded_context: &'static str,
1828 /// The command type a firing submits.
1829 pub target_command_type: &'static str,
1830 /// `ScheduleDeadline::START_FROM`.
1831 pub start_from: DeadlinePollStartFrom,
1832}
1833
1834/// One registered `CancelDeadline`'s own static identity.
1835#[derive(Debug, Clone, Copy)]
1836pub struct CancelDeadlineInfo {
1837 /// `CancelDeadline::NAME`: the cancel reactor's identity and the name of
1838 /// its cursor.
1839 pub name: &'static str,
1840 /// The bounded context the cancelling event type lives in.
1841 pub source_bounded_context: &'static str,
1842 /// The event type whose events cancel deadlines.
1843 pub source_event_type: &'static str,
1844 /// The paired `ScheduleDeadline::NAME` whose pending deadlines this
1845 /// cancels.
1846 pub deadline_schedule_name: &'static str,
1847 /// `Self::Deadline::Source::BOUNDED_CONTEXT` - where the paired
1848 /// `ScheduleDeadline`'s own `deadlines`/`deadline_cursors` rows
1849 /// physically live (mirroring `CrossContextRoute`'s own
1850 /// `source_bounded_context` placement choice for its cursor table),
1851 /// which can differ from this cancel reactor's own
1852 /// `source_bounded_context` above - the cancelling event and the
1853 /// deadline it cancels aren't required to live in the same bounded
1854 /// context, the same generality `CrossContextRoute::Source`/`Target`
1855 /// already has.
1856 pub deadline_schedule_bounded_context: &'static str,
1857 /// `Self::Deadline::Source::NAME` - the event type the paired
1858 /// `ScheduleDeadline` schedules from. A cancel waits until that
1859 /// schedule has processed every such event that precedes the
1860 /// cancelling one (docs/architecture.md §130).
1861 pub deadline_schedule_source_event_type: &'static str,
1862 /// `CancelDeadline::START_FROM`.
1863 pub start_from: DeadlinePollStartFrom,
1864}
1865
1866/// Type-erased dispatch to a bounded context's own typed
1867/// `ScheduleDeadline::schedule` - `CrossContextRouteDispatcher`'s own
1868/// counterpart for this trait.
1869pub trait ScheduleDeadlineDispatcher: Send + Sync {
1870 /// Every registered schedule, across every bounded context - not
1871 /// scoped to one bounded context, the same reasoning
1872 /// `CrossContextRouteDispatcher::routes` already gives.
1873 fn schedules(&self) -> Vec<ScheduleDeadlineInfo>;
1874
1875 /// `schedule_name` names one of `schedules()`'s own entries;
1876 /// `source_payload_json` is the triggering `Source` event's own
1877 /// stored payload. Outer `None` - not registered at all. `Some(Err(e))` -
1878 /// the stored payload didn't deserialize into `Source::Payload`.
1879 /// `Some(Ok(None))` - `schedule()` itself decided this occurrence
1880 /// doesn't apply. `Some(Ok(Some(spec)))` - ready to insert as a
1881 /// `pending` row, `Target`'s own JSON payload already resolved.
1882 fn schedule(
1883 &self,
1884 schedule_name: &str,
1885 source_payload_json: &str,
1886 ) -> Option<Result<Option<ErasedDeadlineSpec>, serde_json::Error>>;
1887}
1888
1889/// `ScheduleDeadlineDispatcher::schedule`'s own type-erased output -
1890/// `DeadlineSpec<P>` with `P` resolved to its already-serialised JSON,
1891/// the same "erase the payload's own concrete type at the dispatcher
1892/// boundary" treatment every other dispatcher trait in this module
1893/// already gives its own typed payloads. `recurrence` is carried through
1894/// verbatim (issue #95) - the dispatcher boundary erases the
1895/// payload type only, never the schedule's own recurrence decision.
1896#[derive(Debug, Clone)]
1897pub struct ErasedDeadlineSpec {
1898 /// `DeadlineSpec::fire_at`.
1899 pub fire_at: chrono::DateTime<chrono::Utc>,
1900 /// `DeadlineSpec::expires_at`, carried through verbatim (
1901 /// issue #97).
1902 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
1903 /// `DeadlineSpec::tags`.
1904 pub tags: Vec<Tag>,
1905 /// `DeadlineSpec::payload`, serialized to JSON.
1906 pub payload_json: String,
1907 /// `DeadlineSpec::recurrence`, carried through as is.
1908 pub recurrence: Option<RecurrenceSpec>,
1909}
1910
1911/// Type-erased dispatch to a bounded context's own typed
1912/// `CancelDeadline::cancel_tags`.
1913pub trait CancelDeadlineDispatcher: Send + Sync {
1914 /// Every registered cancel reactor, across every bounded context.
1915 fn cancels(&self) -> Vec<CancelDeadlineInfo>;
1916
1917 /// `cancel_name` names one of `cancels()`'s own entries;
1918 /// `source_payload_json` is the triggering `Source` event's own
1919 /// stored payload. Outer `None` - not registered at all. `Some(Err(e))` -
1920 /// the stored payload didn't deserialize into `Source::Payload`.
1921 /// `Some(Ok(None))` - `cancel_tags()` itself decided this occurrence
1922 /// doesn't apply. `Some(Ok(Some(tags)))` - cancel every still-`pending`
1923 /// row the paired `ScheduleDeadline` owns whose own tags contain
1924 /// these.
1925 fn cancel_tags(
1926 &self,
1927 cancel_name: &str,
1928 source_payload_json: &str,
1929 ) -> Option<Result<Option<Vec<Tag>>, serde_json::Error>>;
1930}