turnframe_store_postgres/lib.rs
1//! `turnframe-store-postgres`: the PostgreSQL implementation of the Turnframe
2//! persistence contract.
3//!
4//! # This crate is optional
5//!
6//! The contract is [`turnframe-store`](turnframe_store): seven object-safe
7//! traits and an executable conformance suite that proves an implementation
8//! right. This crate is *one* implementation of them. An adopter with an
9//! existing schema, another database, or a different operational story can
10//! implement the traits themselves and never depend on this crate — the runtime
11//! cannot tell the difference, and the conformance suite will say so either way.
12//!
13//! What you get by using it is a schema that has already been argued about: the
14//! constraints below are where the rules of the contract live, so a race that
15//! would break an invariant loses on an index rather than on a lucky
16//! interleaving.
17//!
18//! # Getting started
19//!
20//! ```rust,no_run
21//! use turnframe_core::ids::{AccountId, ConversationId};
22//! use turnframe_store::prelude::*;
23//! use turnframe_store_postgres::PgStores;
24//!
25//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
26//! let store = PgStores::connect("postgres://turnframe@localhost/turnframe").await?;
27//! store.migrate().await?;
28//!
29//! let stores: Stores = store.stores()?;
30//! let account = AccountId::from("aurora");
31//! let conversation = ConversationId::new();
32//!
33//! stores
34//! .conversations()
35//! .create_conversation(ConversationRecord::new(
36//! conversation,
37//! account.clone(),
38//! chrono::Utc::now(),
39//! ))
40//! .await?;
41//!
42//! // Another tenant cannot tell it apart from a conversation that never existed.
43//! assert_eq!(
44//! stores
45//! .conversations()
46//! .load_conversation(&AccountId::from("other"), &conversation)
47//! .await,
48//! Err(StoreError::NotFound)
49//! );
50//! # Ok(())
51//! # }
52//! ```
53//!
54//! # Where each rule lives
55//!
56//! | Rule | How it is enforced |
57//! |---|---|
58//! | at most one open blocking card per case (I5) | the partial unique index `tf_one_open_blocking_interaction_per_case` |
59//! | one admission per idempotency key (I14) | `UNIQUE (account_id, idempotency_key)` plus `INSERT … ON CONFLICT DO NOTHING`, then a re-read that replays the winner |
60//! | one external action per destination | `UNIQUE (destination, idempotency_key)` |
61//! | a claimed outbox row is one dispatcher's | `SELECT … FOR UPDATE SKIP LOCKED` inside the claiming statement |
62//! | compare-and-swap, never blind overwrite | the expected state is the `WHERE` clause of the write; zero affected rows means the precondition failed |
63//! | a bundle is all or nothing | one transaction, committed only after the last item succeeded |
64//! | tenant isolation | `account_id` leads every primary key and every index, so a query that forgets it cannot use one |
65//!
66//! # Runtime-checked queries, on purpose
67//!
68//! Every statement in this crate goes through `sqlx::query` and reads its
69//! columns by name. None of them uses the `sqlx::query!` family.
70//!
71//! Those macros check SQL against a live database *at compile time*, which is a
72//! real benefit and the wrong trade for a published library: it makes the crate
73//! unbuildable in a clean checkout unless a database is reachable or a
74//! `.sqlx` cache is committed and kept in step with every edit. A contributor
75//! with no PostgreSQL, a `cargo install`, a `docs.rs` build and a downstream
76//! `cargo vendor` would all fail on something that has nothing to do with their
77//! change.
78//!
79//! The check that macros would have given is bought back by the conformance
80//! suite instead: it runs the whole persistence contract against a real
81//! PostgreSQL 16, so a column renamed on one side and not the other fails a test
82//! rather than a build — later, but against behaviour rather than shape.
83//! [`sqlx::migrate!`] is still a macro and still used: it reads `migrations/`
84//! while compiling and needs no database.
85//!
86//! # Assumptions this adapter makes
87//!
88//! * **`READ COMMITTED`.** PostgreSQL's default. The idempotency admission and
89//! every compare-and-swap rely on a statement re-reading a row another
90//! transaction has just committed. At `REPEATABLE READ` those become
91//! serialization failures, reported as `Conflict` — correct, but it turns
92//! routine contention into caller-visible refusals.
93//! * **Microsecond timestamps.** `timestamptz` keeps microseconds; instants this
94//! adapter stamps are truncated to match, so a value written and read back
95//! compares equal.
96//! * **One schema.** Set [`PgStoreConfig::schema`] to keep the tables out of
97//! `public`; [`PgStores::migrate`] creates it.
98//!
99//! See the [README](https://github.com/turnframe-rs/turnframe/blob/main/crates/turnframe-store-postgres/README.md)
100//! for the schema table by table and for running the conformance suite.
101
102#![forbid(unsafe_code)]
103#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
104
105/// The crate README, compiled as a doc-test so its examples cannot rot.
106#[cfg(doctest)]
107#[doc = include_str!("../README.md")]
108mod readme {}
109
110mod codec;
111mod commit;
112mod config;
113mod conversations;
114pub mod error;
115mod events;
116mod interactions;
117mod journal;
118mod outbox;
119mod replay;
120mod store;
121
122pub use crate::config::{ConfigError, PgStoreConfig};
123pub use crate::store::{MIGRATOR, PgStores};
124
125/// Applies every migration this crate carries to a pool the caller owns.
126///
127/// Equivalent to [`PgStores::migrate`] without the store, for a deployment that
128/// migrates from a separate binary. It does not create a schema: point the
129/// pool's `search_path` at one that exists, or use [`PgStores::migrate`], which
130/// creates the configured schema first.
131///
132/// # Errors
133/// * [`MigrateError`](sqlx::migrate::MigrateError) when a migration fails or an
134/// already-applied migration no longer matches its recorded checksum.
135pub async fn migrate(pool: &sqlx::PgPool) -> Result<(), sqlx::migrate::MigrateError> {
136 MIGRATOR.run(pool).await
137}