1use gwk_domain::checkpoint::{CHECKPOINT_EVENT_INTERVAL, CHECKPOINT_INTERVAL_SECS};
21use gwk_domain::envelope::{Actor, EventEnvelope, INLINE_PAYLOAD_MAX_BYTES, Origin, PayloadRef};
22use gwk_domain::ids::{
23 AggregateId, CorrelationId, EventId, FenceToken, IdempotencyKey, ProjectId, Seq, Timestamp,
24};
25use gwk_domain::port::{AppendError, EventStore, MAX_READ_LIMIT, StorageError};
26use secrecy::{ExposeSecret, SecretString};
27use sqlx::postgres::PgPoolOptions;
28use sqlx::{Executor, PgConnection, PgPool, Row, postgres::PgRow};
29use tokio::sync::{Semaphore, SemaphorePermit};
30
31use crate::blob::store::PgBlobStore;
35use crate::checkpoint;
36use crate::error::Result as KernelResult;
37use crate::numeric::{from_numeric_text, to_numeric_text};
38use crate::writer::claim_epoch;
39
40pub const MAX_INFLIGHT_APPENDS: usize = 64;
46
47pub const EVENT_CHANNEL: &str = "gwk_event";
51
52const NO_FENCE_PRESENTED: u64 = 0;
55
56macro_rules! event_columns {
67 () => {
68 "seq::text AS seq_text, event_id, project_id, aggregate_type, \
69 aggregate_id, aggregate_version, event_type, schema_version, \
70 to_json(occurred_at) #>> '{}' AS occurred_at, \
71 to_json(appended_at) #>> '{}' AS appended_at, \
72 actor, origin, causation_id, correlation_id, idempotency_key, payload, payload_ref"
73 };
74}
75
76pub async fn connect_pool(
83 database_url: &SecretString,
84 max_connections: u32,
85) -> KernelResult<PgPool> {
86 let pool = PgPoolOptions::new()
87 .max_connections(max_connections)
88 .after_connect(|conn, _meta| {
89 Box::pin(async move {
90 conn.execute("SET TIME ZONE 'UTC'").await?;
91 Ok(())
92 })
93 })
94 .connect(database_url.expose_secret())
95 .await?;
96 Ok(pool)
97}
98
99pub struct PgEventStore {
101 pool: PgPool,
102 admission: Semaphore,
103 boot_epoch: i64,
104 blobs: Option<PgBlobStore>,
107}
108
109impl PgEventStore {
110 pub async fn open(pool: PgPool) -> KernelResult<Self> {
115 Self::with_capacity(pool, MAX_INFLIGHT_APPENDS).await
116 }
117
118 pub async fn with_capacity(pool: PgPool, inflight: usize) -> KernelResult<Self> {
123 let boot_epoch = claim_epoch(&pool).await?;
124 Ok(Self {
125 pool,
126 admission: Semaphore::new(inflight),
127 boot_epoch,
128 blobs: None,
129 })
130 }
131
132 pub fn open_reader(pool: PgPool) -> Self {
144 Self {
145 pool,
146 admission: Semaphore::new(MAX_INFLIGHT_APPENDS),
147 boot_epoch: 0,
148 blobs: None,
149 }
150 }
151
152 #[must_use]
161 pub fn with_blobs(mut self, blobs: PgBlobStore) -> Self {
162 self.blobs = Some(blobs);
163 self
164 }
165
166 pub fn blobs(&self) -> Option<&PgBlobStore> {
168 self.blobs.as_ref()
169 }
170
171 pub(crate) async fn checkpoint_if_due(
190 &self,
191 tx: &mut PgConnection,
192 writer: &WriterState,
193 through: Seq,
194 created_at: &Timestamp,
195 ) -> Result<(), AppendError> {
196 let Some(blobs) = &self.blobs else {
197 return Ok(());
198 };
199 if !writer.checkpoint_due(through.value()) {
200 return Ok(());
201 }
202 checkpoint::snapshot(tx, blobs, through, created_at)
203 .await
204 .map(|_| ())
205 .map_err(|e| AppendError::Storage(format!("checkpoint: {e}")))
206 }
207
208 pub async fn checkpoint_at_watermark(&self) -> Result<Option<Seq>, AppendError> {
227 let Some(blobs) = &self.blobs else {
228 return Ok(None);
229 };
230 let mut tx = self
231 .pool
232 .begin()
233 .await
234 .map_err(|e| append_storage("begin a shutdown checkpoint", e))?;
235 let _writer = self.lock_writer(&mut tx).await?;
238 let at: (Option<String>, String) =
239 sqlx::query_as("SELECT max(seq)::text, to_json(now()) #>> '{}' FROM gwk.event")
240 .fetch_one(&mut *tx)
241 .await
242 .map_err(|e| append_storage("read the watermark", e))?;
243 let Some(watermark) = at.0 else {
244 return Ok(None);
247 };
248 let through =
249 Seq::new(from_numeric_text(&watermark).map_err(|e| append_storage("watermark", e))?);
250 checkpoint::snapshot(&mut tx, blobs, through, &Timestamp::new(at.1))
251 .await
252 .map_err(|e| AppendError::Storage(format!("shutdown checkpoint: {e}")))?;
253 tx.commit()
254 .await
255 .map_err(|e| append_storage("commit a shutdown checkpoint", e))?;
256 Ok(Some(through))
257 }
258
259 pub fn boot_epoch(&self) -> i64 {
261 self.boot_epoch
262 }
263
264 pub fn pool(&self) -> &PgPool {
265 &self.pool
266 }
267
268 pub(crate) fn admit(&self) -> Result<SemaphorePermit<'_>, AppendError> {
273 self.admission.try_acquire().map_err(|_| {
274 AppendError::Storage(format!(
275 "append queue is full ({MAX_INFLIGHT_APPENDS} in flight)"
276 ))
277 })
278 }
279
280 pub(crate) async fn lock_writer(
290 &self,
291 tx: &mut PgConnection,
292 ) -> Result<WriterState, AppendError> {
293 let writer = sqlx::query(
294 "SELECT epoch, fence_token::text AS fence_text, next_seq::text AS next_seq_text, \
295 checkpoint_seq::text AS checkpoint_seq_text, \
296 now() - checkpoint_at >= make_interval(secs => $1::double precision) \
297 AS checkpoint_overdue \
298 FROM gwk_internal.writer WHERE id = 1 FOR UPDATE",
299 )
300 .bind(CHECKPOINT_INTERVAL_SECS as f64)
301 .fetch_optional(&mut *tx)
302 .await
303 .map_err(|e| append_storage("lock writer row", e))?
304 .ok_or_else(|| {
305 AppendError::Storage(
306 "gwk_internal.writer has no singleton row: database not initialized".to_owned(),
307 )
308 })?;
309
310 let epoch: i64 = writer
311 .try_get("epoch")
312 .map_err(|e| append_storage("column epoch", e))?;
313 if epoch != self.boot_epoch {
314 return Err(AppendError::Storage(format!(
318 "writer epoch {} was superseded by {epoch}: another kernel took write authority",
319 self.boot_epoch
320 )));
321 }
322
323 let current_fence: Option<u64> = writer
324 .try_get::<Option<String>, _>("fence_text")
325 .map_err(|e| append_storage("column fence_token", e))?
326 .map(|text| from_numeric_text(&text))
327 .transpose()
328 .map_err(|e| append_storage("column fence_token", e))?;
329 let next_seq = from_numeric_text(
330 &writer
331 .try_get::<String, _>("next_seq_text")
332 .map_err(|e| append_storage("column next_seq", e))?,
333 )
334 .map_err(|e| append_storage("column next_seq", e))?;
335
336 let checkpoint_seq = from_numeric_text(
337 &writer
338 .try_get::<String, _>("checkpoint_seq_text")
339 .map_err(|e| append_storage("column checkpoint_seq", e))?,
340 )
341 .map_err(|e| append_storage("column checkpoint_seq", e))?;
342 let checkpoint_overdue: bool = writer
343 .try_get("checkpoint_overdue")
344 .map_err(|e| append_storage("column checkpoint_at", e))?;
345
346 Ok(WriterState {
347 next_seq,
348 current_fence,
349 checkpoint_seq,
350 checkpoint_overdue,
351 })
352 }
353
354 pub(crate) async fn append_locked(
362 &self,
363 tx: &mut PgConnection,
364 writer: &WriterState,
365 expected_version: u32,
366 fence: Option<FenceToken>,
367 events: &[EventEnvelope],
368 ) -> Result<Appended, AppendError> {
369 validate_batch(expected_version, events)?;
370 let first = &events[0];
371 let aggregate_type = first.aggregate_type.clone();
372 let aggregate_id = first.aggregate_id.as_str().to_owned();
373
374 if let Some(current) = writer.current_fence {
378 let presented = fence.map(|f| f.value()).unwrap_or(NO_FENCE_PRESENTED);
379 if presented != current {
380 return Err(AppendError::Fenced {
381 presented: FenceToken::new(presented),
382 current: FenceToken::new(current),
383 });
384 }
385 }
386
387 let keys: Vec<String> = events
392 .iter()
393 .filter_map(|e| e.idempotency_key.as_ref())
394 .map(|k| k.as_str().to_owned())
395 .collect();
396 if !keys.is_empty() {
397 let stored = sqlx::query(concat!(
398 "SELECT ",
399 event_columns!(),
400 " FROM gwk.event \
401 WHERE aggregate_type = $1 AND aggregate_id = $2 AND idempotency_key = ANY($3) \
402 ORDER BY seq"
403 ))
404 .bind(&aggregate_type)
405 .bind(&aggregate_id)
406 .bind(&keys)
407 .fetch_all(&mut *tx)
408 .await
409 .map_err(|e| append_storage("idempotency lookup", e))?;
410
411 if !stored.is_empty() {
412 if stored.len() == events.len() && keys.len() == events.len() {
413 let stored = stored
414 .iter()
415 .map(row_to_envelope)
416 .collect::<Result<Vec<_>, _>>()
417 .map_err(|e| AppendError::Storage(e.0))?;
418 if let Some((stored, presented)) = stored
426 .iter()
427 .zip(events)
428 .find(|(stored, presented)| !is_replay_of(stored, presented))
429 {
430 return Err(AppendError::MalformedBatch(format!(
431 "idempotency key {:?} already named {}/{} version {} in project {}: \
432 a retry must present the identical batch",
433 presented
434 .idempotency_key
435 .as_ref()
436 .map(|k| k.as_str())
437 .unwrap_or_default(),
438 stored.aggregate_type,
439 stored.aggregate_id,
440 stored.aggregate_version,
441 stored.project_id,
442 )));
443 }
444 return Ok(Appended {
447 events: stored,
448 replayed: true,
449 });
450 }
451 return Err(AppendError::MalformedBatch(format!(
452 "{} of {} idempotency keys already landed for {aggregate_type}/{aggregate_id}: \
453 a retry must present the identical batch",
454 stored.len(),
455 events.len()
456 )));
457 }
458 }
459
460 let actual = current_aggregate_version(&mut *tx, &aggregate_type, &aggregate_id)
462 .await
463 .map_err(|e| AppendError::Storage(e.0))?;
464 if actual != expected_version {
465 return Err(AppendError::VersionConflict {
466 actual,
467 expected: expected_version,
468 });
469 }
470
471 let mut appended = Vec::with_capacity(events.len());
472 for (offset, event) in events.iter().enumerate() {
473 let seq = writer
474 .next_seq
475 .checked_add(offset as u64)
476 .ok_or_else(|| AppendError::Storage("global sequence exhausted".to_owned()))?;
477 let row = sqlx::query(concat!(
480 "INSERT INTO gwk.event ( \
481 seq, event_id, project_id, aggregate_type, aggregate_id, aggregate_version, \
482 event_type, schema_version, occurred_at, appended_at, actor, origin, \
483 causation_id, correlation_id, idempotency_key, payload, payload_ref) \
484 VALUES ($1::numeric, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, now(), \
485 $10, $11, $12, $13, $14, $15, $16) \
486 RETURNING ",
487 event_columns!()
488 ))
489 .bind(to_numeric_text(seq))
490 .bind(event.event_id.as_str())
491 .bind(event.project_id.as_str())
492 .bind(&event.aggregate_type)
493 .bind(event.aggregate_id.as_str())
494 .bind(i64::from(event.aggregate_version))
495 .bind(&event.event_type)
496 .bind(i64::from(event.schema_version))
497 .bind(event.occurred_at.as_str())
498 .bind(serde_json::to_value(&event.actor).map_err(|e| append_storage("actor", e))?)
499 .bind(serde_json::to_value(&event.origin).map_err(|e| append_storage("origin", e))?)
500 .bind(event.causation_id.as_ref().map(|v| v.as_str()))
501 .bind(event.correlation_id.as_ref().map(|v| v.as_str()))
502 .bind(event.idempotency_key.as_ref().map(|v| v.as_str()))
503 .bind(&event.payload)
504 .bind(
505 event
506 .payload_ref
507 .as_ref()
508 .map(serde_json::to_value)
509 .transpose()
510 .map_err(|e| append_storage("payload_ref", e))?,
511 )
512 .fetch_one(&mut *tx)
513 .await
514 .map_err(|e| append_storage("insert event", e))?;
515 appended.push(row_to_envelope(&row).map_err(|e| AppendError::Storage(e.0))?);
516 }
517
518 let advanced = writer
519 .next_seq
520 .checked_add(events.len() as u64)
521 .ok_or_else(|| AppendError::Storage("global sequence exhausted".to_owned()))?;
522 sqlx::query("UPDATE gwk_internal.writer SET next_seq = $1::numeric WHERE id = 1")
523 .bind(to_numeric_text(advanced))
524 .execute(&mut *tx)
525 .await
526 .map_err(|e| append_storage("advance sequence", e))?;
527
528 sqlx::query("SELECT pg_notify($1, $2)")
531 .bind(EVENT_CHANNEL)
532 .bind(to_numeric_text(advanced - 1))
533 .execute(&mut *tx)
534 .await
535 .map_err(|e| append_storage("notify", e))?;
536
537 Ok(Appended {
538 events: appended,
539 replayed: false,
540 })
541 }
542}
543
544pub(crate) struct WriterState {
547 pub(crate) next_seq: u64,
548 pub(crate) current_fence: Option<u64>,
549 pub(crate) checkpoint_seq: u64,
551 pub(crate) checkpoint_overdue: bool,
555}
556
557impl WriterState {
558 fn checkpoint_due(&self, through: u64) -> bool {
560 self.checkpoint_overdue
561 || through.saturating_sub(self.checkpoint_seq) >= CHECKPOINT_EVENT_INTERVAL
562 }
563}
564
565pub(crate) struct Appended {
571 pub(crate) events: Vec<EventEnvelope>,
572 pub(crate) replayed: bool,
573}
574
575pub(crate) async fn events_for_key(
590 conn: &mut PgConnection,
591 project_id: &str,
592 aggregate_type: &str,
593 aggregate_id: &str,
594 key: &str,
595) -> Result<Vec<EventEnvelope>, StorageError> {
596 let rows = sqlx::query(concat!(
597 "SELECT ",
598 event_columns!(),
599 " FROM gwk.event \
600 WHERE idempotency_key = $4 \
601 AND (project_id = $1 OR (aggregate_type = $2 AND aggregate_id = $3)) \
602 ORDER BY seq"
603 ))
604 .bind(project_id)
605 .bind(aggregate_type)
606 .bind(aggregate_id)
607 .bind(key)
608 .fetch_all(conn)
609 .await
610 .map_err(|e| storage("idempotency lookup", e))?;
611 rows.iter().map(row_to_envelope).collect()
612}
613
614pub(crate) async fn current_aggregate_version(
616 conn: &mut PgConnection,
617 aggregate_type: &str,
618 aggregate_id: &str,
619) -> Result<u32, StorageError> {
620 let actual: Option<i64> = sqlx::query_scalar(
621 "SELECT max(aggregate_version) FROM gwk.event \
622 WHERE aggregate_type = $1 AND aggregate_id = $2",
623 )
624 .bind(aggregate_type)
625 .bind(aggregate_id)
626 .fetch_one(conn)
627 .await
628 .map_err(|e| storage("read aggregate version", e))?;
629 u32::try_from(actual.unwrap_or(0)).map_err(|e| storage("aggregate_version out of range", e))
630}
631
632pub fn validate_batch(expected_version: u32, events: &[EventEnvelope]) -> Result<(), AppendError> {
637 let malformed = |reason: String| Err(AppendError::MalformedBatch(reason));
638 let Some(first) = events.first() else {
639 return malformed("empty batch".to_owned());
640 };
641 for (offset, event) in events.iter().enumerate() {
642 if event.aggregate_type != first.aggregate_type || event.aggregate_id != first.aggregate_id
643 {
644 return malformed(format!(
645 "batch mixes aggregates: {}/{} and {}/{}",
646 first.aggregate_type,
647 first.aggregate_id.as_str(),
648 event.aggregate_type,
649 event.aggregate_id.as_str()
650 ));
651 }
652 let want = u64::from(expected_version) + 1 + offset as u64;
655 if want > u64::from(u32::MAX) {
656 return malformed(format!("aggregate_version would exceed {}", u32::MAX));
657 }
658 if u64::from(event.aggregate_version) != want {
659 return malformed(format!(
660 "aggregate_version {} at offset {offset}: expected {want} \
661 (contiguous from expected_version + 1)",
662 event.aggregate_version
663 ));
664 }
665 if event.schema_version == 0 {
673 return malformed(format!(
674 "schema_version 0 at offset {offset} is not a version"
675 ));
676 }
677 let inline = serde_json::to_vec(&event.payload)
679 .map(|bytes| bytes.len())
680 .unwrap_or(usize::MAX);
681 if inline > INLINE_PAYLOAD_MAX_BYTES {
682 return malformed(format!(
683 "inline payload at offset {offset} is {inline} bytes, over the \
684 {INLINE_PAYLOAD_MAX_BYTES} bound — use payload_ref"
685 ));
686 }
687 if let Some(key) = &event.idempotency_key
692 && events[..offset]
693 .iter()
694 .any(|earlier| earlier.idempotency_key.as_ref() == Some(key))
695 {
696 return malformed(format!(
697 "idempotency key {:?} appears twice in one batch",
698 key.as_str()
699 ));
700 }
701 }
702 Ok(())
703}
704
705fn storage(context: &str, error: impl std::fmt::Display) -> StorageError {
706 StorageError(format!("{context}: {error}"))
707}
708
709fn append_storage(context: &str, error: impl std::fmt::Display) -> AppendError {
710 AppendError::Storage(format!("{context}: {error}"))
711}
712
713pub(crate) async fn read_page<'e, E>(
722 executor: E,
723 cursor: Option<Seq>,
724 limit: usize,
725) -> Result<Vec<EventEnvelope>, StorageError>
726where
727 E: sqlx::PgExecutor<'e>,
728{
729 let limit = limit.min(MAX_READ_LIMIT) as i64;
732 let rows = sqlx::query(concat!(
739 "SELECT ",
740 event_columns!(),
741 " FROM gwk.event \
742 WHERE $1::numeric IS NULL OR seq > $1::numeric \
743 ORDER BY seq LIMIT $2"
744 ))
745 .bind(cursor.map(|c| to_numeric_text(c.value())))
746 .bind(limit)
747 .fetch_all(executor)
748 .await
749 .map_err(|e| storage("read_from", e))?;
750 rows.iter().map(row_to_envelope).collect()
751}
752
753fn row_to_envelope(row: &PgRow) -> Result<EventEnvelope, StorageError> {
754 let get = |name: &str| -> Result<String, StorageError> {
755 row.try_get::<String, _>(name)
756 .map_err(|e| storage(&format!("column {name}"), e))
757 };
758 let opt = |name: &str| -> Result<Option<String>, StorageError> {
759 row.try_get::<Option<String>, _>(name)
760 .map_err(|e| storage(&format!("column {name}"), e))
761 };
762 let json = |name: &str| -> Result<serde_json::Value, StorageError> {
763 row.try_get::<serde_json::Value, _>(name)
764 .map_err(|e| storage(&format!("column {name}"), e))
765 };
766 let seq_text = get("seq_text")?;
767 let seq = from_numeric_text(&seq_text).map_err(|e| storage("column seq", e))?;
768 let aggregate_version: i64 = row
769 .try_get("aggregate_version")
770 .map_err(|e| storage("column aggregate_version", e))?;
771 let schema_version: i64 = row
775 .try_get("schema_version")
776 .map_err(|e| storage("column schema_version", e))?;
777 let payload_ref: Option<serde_json::Value> = row
778 .try_get("payload_ref")
779 .map_err(|e| storage("column payload_ref", e))?;
780
781 Ok(EventEnvelope {
782 event_id: EventId::new(get("event_id")?),
783 project_id: ProjectId::new(get("project_id")?),
784 aggregate_type: get("aggregate_type")?,
785 aggregate_id: AggregateId::new(get("aggregate_id")?),
786 aggregate_version: u32::try_from(aggregate_version)
790 .map_err(|e| storage("aggregate_version out of range", e))?,
791 event_type: get("event_type")?,
792 schema_version: u32::try_from(schema_version)
793 .map_err(|e| storage("schema_version out of range", e))?,
794 global_sequence: Seq::new(seq),
795 occurred_at: Timestamp::new(get("occurred_at")?),
796 appended_at: Timestamp::new(get("appended_at")?),
797 actor: serde_json::from_value::<Actor>(json("actor")?)
798 .map_err(|e| storage("column actor", e))?,
799 origin: serde_json::from_value::<Origin>(json("origin")?)
800 .map_err(|e| storage("column origin", e))?,
801 causation_id: opt("causation_id")?.map(EventId::new),
802 correlation_id: opt("correlation_id")?.map(CorrelationId::new),
803 idempotency_key: opt("idempotency_key")?.map(IdempotencyKey::new),
804 payload: json("payload")?,
805 payload_ref: payload_ref
806 .map(serde_json::from_value::<PayloadRef>)
807 .transpose()
808 .map_err(|e| storage("column payload_ref", e))?,
809 })
810}
811
812fn is_replay_of(stored: &EventEnvelope, presented: &EventEnvelope) -> bool {
825 let mut normalized = presented.clone();
826 normalized.global_sequence = stored.global_sequence;
827 normalized.appended_at = stored.appended_at.clone();
828 normalized.occurred_at = stored.occurred_at.clone();
829 normalized == *stored
830}
831
832impl EventStore for PgEventStore {
833 async fn append(
834 &self,
835 expected_version: u32,
836 fence: Option<FenceToken>,
837 events: Vec<EventEnvelope>,
838 ) -> Result<Vec<EventEnvelope>, AppendError> {
839 let _permit = self.admit()?;
840 let mut tx = self
841 .pool
842 .begin()
843 .await
844 .map_err(|e| append_storage("begin", e))?;
845 let writer = self.lock_writer(&mut tx).await?;
846 let appended = self
847 .append_locked(&mut tx, &writer, expected_version, fence, &events)
848 .await?;
849 tx.commit().await.map_err(|e| append_storage("commit", e))?;
852 Ok(appended.events)
853 }
854
855 async fn read_from(
856 &self,
857 cursor: Option<Seq>,
858 limit: usize,
859 ) -> Result<Vec<EventEnvelope>, StorageError> {
860 read_page(&self.pool, cursor, limit).await
861 }
862
863 async fn watermark(&self) -> Result<Option<Seq>, StorageError> {
864 let text: Option<String> = sqlx::query_scalar("SELECT max(seq)::text FROM gwk.event")
865 .fetch_one(&self.pool)
866 .await
867 .map_err(|e| storage("watermark", e))?;
868 text.map(|t| from_numeric_text(&t))
869 .transpose()
870 .map(|opt| opt.map(Seq::new))
871 .map_err(|e| storage("watermark", e))
872 }
873
874 async fn grant_fence(&self) -> Result<FenceToken, StorageError> {
875 let text: String = sqlx::query_scalar(
876 "UPDATE gwk_internal.writer \
877 SET fence_token = coalesce(fence_token, 0) + 1 \
878 WHERE id = 1 RETURNING fence_token::text",
879 )
880 .fetch_optional(&self.pool)
881 .await
882 .map_err(|e| storage("grant_fence", e))?
883 .ok_or_else(|| StorageError("gwk_internal.writer has no singleton row".to_owned()))?;
884 from_numeric_text(&text)
885 .map(FenceToken::new)
886 .map_err(|e| storage("grant_fence", e))
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use gwk_domain::ids::Timestamp;
893
894 use super::*;
895
896 fn event(aggregate_id: &str, version: u32) -> EventEnvelope {
897 EventEnvelope {
898 event_id: EventId::new(format!("evt-{aggregate_id}-{version}")),
899 project_id: ProjectId::new("p"),
900 aggregate_type: "task".into(),
901 aggregate_id: AggregateId::new(aggregate_id),
902 aggregate_version: version,
903 event_type: "tick".into(),
904 schema_version: 1,
905 global_sequence: Seq::new(0),
906 occurred_at: Timestamp::new("2026-01-01T00:00:00Z"),
907 appended_at: Timestamp::new("2026-01-01T00:00:00Z"),
908 actor: Actor {
909 kind: "kernel".into(),
910 id: None,
911 },
912 origin: Origin {
913 system: "kernel".into(),
914 r#ref: None,
915 },
916 causation_id: None,
917 correlation_id: None,
918 idempotency_key: None,
919 payload: serde_json::json!({}),
920 payload_ref: None,
921 }
922 }
923
924 #[test]
925 fn a_batch_is_one_aggregate_contiguous_from_the_expected_version() {
926 validate_batch(0, &[event("a", 1), event("a", 2)]).expect("contiguous from 1");
927 validate_batch(7, &[event("a", 8)]).expect("contiguous from expected + 1");
928
929 for (expected, batch, why) in [
930 (0u32, vec![], "empty"),
931 (0, vec![event("a", 2)], "starts past expected + 1"),
932 (0, vec![event("a", 1), event("a", 3)], "gap"),
933 (0, vec![event("a", 1), event("a", 1)], "repeat"),
934 (5, vec![event("a", 1)], "ignores expected_version"),
935 ] {
936 let err = validate_batch(expected, &batch).expect_err(why);
937 assert!(
938 matches!(err, AppendError::MalformedBatch(_)),
939 "{why}: {err:?}"
940 );
941 }
942
943 let mixed = vec![event("a", 1), event("b", 2)];
944 let err = validate_batch(0, &mixed).expect_err("mixed aggregates");
945 let AppendError::MalformedBatch(reason) = err else {
946 panic!("expected MalformedBatch");
947 };
948 assert!(reason.contains("mixes aggregates"), "{reason}");
949 }
950
951 #[test]
952 fn a_batch_may_not_run_past_the_aggregate_version_ceiling() {
953 let mut last = event("a", u32::MAX);
954 last.aggregate_version = u32::MAX;
955 validate_batch(u32::MAX - 1, &[last.clone()]).expect("the final version is reachable");
956 let err = validate_batch(u32::MAX, &[last]).expect_err("past the ceiling");
958 let AppendError::MalformedBatch(reason) = err else {
959 panic!("expected MalformedBatch");
960 };
961 assert!(reason.contains("would exceed"), "{reason}");
962 }
963
964 #[test]
965 fn the_column_carries_every_schema_version_the_envelope_can_declare() {
966 for version in [1, i32::MAX as u32, i32::MAX as u32 + 1, u32::MAX] {
970 let mut e = event("a", 1);
971 e.schema_version = version;
972 validate_batch(0, &[e]).unwrap_or_else(|err| panic!("schema_version {version}: {err}"));
973 }
974
975 let mut zero = event("a", 1);
976 zero.schema_version = 0;
977 let err = validate_batch(0, &[zero]).expect_err("0 is not a version");
978 let AppendError::MalformedBatch(reason) = err else {
979 panic!("expected MalformedBatch");
980 };
981 assert!(reason.contains("schema_version"), "{reason}");
982 }
983
984 #[test]
985 fn one_key_may_not_appear_twice_in_a_batch() {
986 let key = |k: &str| Some(gwk_domain::ids::IdempotencyKey::new(k));
987 let mut a = event("a", 1);
988 let mut b = event("a", 2);
989 a.idempotency_key = key("same");
990 b.idempotency_key = key("same");
991 let err = validate_batch(0, &[a.clone(), b.clone()]).expect_err("repeated key");
992 let AppendError::MalformedBatch(reason) = err else {
993 panic!("expected MalformedBatch");
994 };
995 assert!(reason.contains("twice in one batch"), "{reason}");
996
997 b.idempotency_key = key("other");
999 validate_batch(0, &[a.clone(), b.clone()]).expect("distinct keys");
1000 b.idempotency_key = None;
1001 validate_batch(0, &[a, b]).expect("a mix of keyed and unkeyed");
1002 }
1003
1004 #[test]
1005 fn an_oversized_inline_payload_is_refused_before_it_reaches_the_log() {
1006 let mut big = event("a", 1);
1007 big.payload = serde_json::json!({ "blob": "x".repeat(INLINE_PAYLOAD_MAX_BYTES) });
1008 let err = validate_batch(0, &[big]).expect_err("oversized payload");
1009 let AppendError::MalformedBatch(reason) = err else {
1010 panic!("expected MalformedBatch");
1011 };
1012 assert!(reason.contains("payload_ref"), "{reason}");
1013 }
1014}