Skip to main content

autumn_web/
repository.rs

1//! Repository support types for framework-generated CRUD operations.
2//!
3//! Generated `#[repository]` read-only methods (`find_by_id`, `find_all`,
4//! `count`, `paginate`, `cursor_page`, derived `find_by_*`, full-text-search
5//! reads) route to the configured read replica automatically: set
6//! `database.replica_url` and the extractor snapshots a [`ReadRoute`] per
7//! request via [`crate::AppState::read_pool`]. Mutating methods (`save`,
8//! `update`, `delete_by_id`, bulk writes) always run on the primary pool.
9//! Pin a read-after-write-sensitive repository to the primary with
10//! `#[repository(Model, primary_reads)]`, or pin a single call chain with
11//! the generated `on_primary()` method. When no replica is configured, all
12//! methods use the primary — nothing changes for single-pool apps.
13//!
14//! Sharded repositories built from a
15//! [`ShardedDb`](crate::sharding::ShardedDb) via the generated `from_shard`
16//! constructor get the same treatment **per shard**: reads route to the
17//! shard's replica when one is configured and healthy (honoring the shard's
18//! `replica_fallback` policy via
19//! [`Shard::read_route`](crate::sharding::Shard::read_route)), writes stay on
20//! the shard primary, and `primary_reads` / `on_primary()` pin reads back to
21//! the shard primary.
22//!
23//! [`RepositoryError`] surfaces typed errors that arise during repository
24//! operations — most notably optimistic-lock conflicts when two replicas
25//! write the same row concurrently.
26
27use sha2::{Digest, Sha256};
28use thiserror::Error;
29
30// Declarative counter caches (#1325) live in their own module but are part of
31// the repository surface as far as callers are concerned — `#[model]`-emitted
32// code, generated repositories and application escape hatches all name them
33// through `autumn_web::repository::…`, next to `AutumnDependents`.
34pub use crate::counter_cache::{
35    AutumnCounterCaches, ChildState, CounterCacheSpec, TenantScope, counter_cache_after_insert,
36    counter_cache_after_insert_by_id, counter_cache_after_insert_many, counter_cache_after_update,
37    counter_cache_after_update_many, counter_cache_after_upsert_many, counter_cache_apply_delta,
38    counter_cache_apply_delta_by_child_id, counter_cache_before_delete_by_id,
39    counter_cache_before_delete_many, counter_cache_before_detach_many,
40    counter_cache_before_restore_by_id, counter_cache_capture_fks, counter_cache_capture_fks_many,
41    counter_cache_recompute, counter_cache_serialize_self_referential,
42};
43#[doc(inline)]
44pub use crate::counter_cache::{Captured, CapturedContribution, CapturedContributions};
45
46// Derivations (#1769) are maintained through the counter-cache specs above, so
47// the hooks a generated repository calls are already in scope from that
48// re-export. What `#[model]` needs additionally is the registry it submits each
49// declared derivation into.
50#[doc(inline)]
51pub use crate::derivation::{DerivationDef, DerivationDescriptor};
52
53// ── Dependent-cascade primitives (issue #2309 follow-up) ─────────────
54//
55// `dependent(...)` (#1369) generates an `__autumn_apply_dependent_on_conn`
56// method on every repository. Three of its five `DependentAction` arms —
57// restrict, nullify and delete_all — are pure dynamic SQL over a table name and
58// a foreign-key column, both of which are already runtime `&str` values in the
59// generated code. Emitting them per repository (254 lines, four
60// `QueryableByName` derives) bought nothing: nothing in them is checked against
61// the model's `schema.rs`.
62//
63// They live here instead, following the same shape `counter_cache_after_insert`
64// and friends already use — `&'static` specs plus `fn` pointers erase the
65// per-column typing, so `M` is the only generic parameter and the SQL compiles
66// once. The `destroy` arm stays generated: it loads and deletes through diesel's
67// typed DSL, recurses through the repository's own pool, and is genuinely
68// model-specific.
69
70/// Refuse the parent delete when any live child still references it.
71///
72/// `live_filter` is the soft-delete-aware `AND` fragment the generated code
73/// baked in (empty for a hard-delete model), which makes `restrict`
74/// soft-delete-aware: an already-soft-deleted child must not block the parent
75/// with a 409, since it is no longer a live orphan.
76///
77/// This is a runtime support function for code generated by Autumn proc macros.
78/// It is semver-exempt; do not call it directly.
79///
80/// # Errors
81///
82/// Returns a 409 [`crate::AutumnError::conflict_msg`] when a dependent row
83/// exists, or the database error if the probe fails.
84#[cfg(feature = "db")]
85#[doc(hidden)]
86pub async fn dependent_restrict(
87    conn: &mut crate::db::RuntimeConnection,
88    table: &str,
89    model_name: &str,
90    live_filter: &str,
91    fk_column: &str,
92    parent_id: i64,
93) -> crate::AutumnResult<()> {
94    use diesel_async::RunQueryDsl;
95
96    // `AS dep_present` (not `AS exists`): `exists` is a Postgres reserved word
97    // and would need quoting.
98    #[derive(diesel::QueryableByName)]
99    struct DepExists {
100        #[diesel(sql_type = diesel::sql_types::Bool)]
101        dep_present: bool,
102    }
103
104    let row: DepExists = diesel::sql_query(format!(
105        "SELECT EXISTS(SELECT 1 FROM \"{table}\" WHERE \"{fk_column}\" = $1{live_filter}) \
106         AS dep_present"
107    ))
108    .bind::<diesel::sql_types::BigInt, _>(parent_id)
109    .get_result::<DepExists>(conn)
110    .await
111    .map_err(crate::AutumnError::from)?;
112
113    if row.dep_present {
114        return Err(crate::AutumnError::conflict_msg(format!(
115            "cannot delete: dependent {model_name} row(s) referencing this record via \
116             \"{table}\".\"{fk_column}\" still exist (dependent = restrict)"
117        )));
118    }
119    Ok(())
120}
121
122/// Resolve the ids of every child row pointing at `parent_id`.
123///
124/// Ordered by `id` so the counter-cache decrements below apply in a stable
125/// order, matching the generated code this replaces.
126#[cfg(feature = "db")]
127async fn dependent_child_ids(
128    conn: &mut crate::db::RuntimeConnection,
129    table: &str,
130    fk_column: &str,
131    parent_id: i64,
132) -> crate::AutumnResult<Vec<i64>> {
133    use diesel_async::RunQueryDsl;
134
135    #[derive(diesel::QueryableByName)]
136    struct ChildId {
137        #[diesel(sql_type = diesel::sql_types::BigInt)]
138        id: i64,
139    }
140
141    Ok(diesel::sql_query(format!(
142        "SELECT id FROM \"{table}\" WHERE \"{fk_column}\" = $1 ORDER BY id"
143    ))
144    .bind::<diesel::sql_types::BigInt, _>(parent_id)
145    .load::<ChildId>(conn)
146    .await
147    .map_err(crate::AutumnError::from)?
148    .into_iter()
149    .map(|row| row.id)
150    .collect())
151}
152
153/// Detach every child row from `parent_id` by nulling its foreign key.
154///
155/// #1325: the children survive but detach, so the leg being cleared loses them
156/// — and ONLY that leg. The ids are resolved first (the FK is about to be NULL,
157/// so they are unrecoverable afterwards) and the decrement runs before the
158/// UPDATE. Decrementing every spec — as [`dependent_delete_all`] correctly does,
159/// since there the rows go away entirely — would permanently undercount the
160/// children's other counter-cached legs.
161///
162/// `has_counter_caches` is the model's `const HAS_COUNTER_CACHES`; when it is
163/// false no id-resolution query is issued at all.
164///
165/// This is a runtime support function for code generated by Autumn proc macros.
166/// It is semver-exempt; do not call it directly.
167///
168/// # Errors
169///
170/// Returns the database error if the id resolution, the counter-cache
171/// decrement, or the UPDATE fails.
172#[cfg(feature = "db")]
173#[doc(hidden)]
174pub async fn dependent_nullify<M: 'static>(
175    conn: &mut crate::db::RuntimeConnection,
176    table: &str,
177    specs: &[CounterCacheSpec<M>],
178    has_counter_caches: bool,
179    fk_column: &str,
180    parent_id: i64,
181) -> crate::AutumnResult<()> {
182    use diesel_async::RunQueryDsl;
183
184    crate::ledger::refuse_out_of_band_write(table, "dependent nullify")?;
185    if has_counter_caches {
186        let ids = dependent_child_ids(conn, table, fk_column, parent_id).await?;
187        counter_cache_before_detach_many(conn, specs, fk_column, &ids).await?;
188    }
189
190    diesel::sql_query(format!(
191        "UPDATE \"{table}\" SET \"{fk_column}\" = NULL WHERE \"{fk_column}\" = $1"
192    ))
193    .bind::<diesel::sql_types::BigInt, _>(parent_id)
194    .execute(conn)
195    .await
196    .map_err(crate::AutumnError::from)?;
197    Ok(())
198}
199
200/// [`dependent_child_ids`], but takes `FOR UPDATE` on the selected rows, in
201/// ascending id order (Postgres only — a no-op clause on `SQLite`, whose
202/// single-writer lock already serializes this).
203///
204/// Codex review, PR #2647: the snapshot this feeds a subsequent bulk DELETE,
205/// so it must be safe to act on. Under READ COMMITTED, `SELECT ... FOR UPDATE`
206/// blocks on a row a concurrent transaction is mid-write to, and once that
207/// transaction commits, re-checks `fk_column = parent_id` against the *new*
208/// row version before including it — so a row reparented away in that window
209/// is correctly excluded, matching the locked `SELECT ... FOR UPDATE` the
210/// per-row Destroy loop has always taken over the same predicate. A plain
211/// unlocked read has no such re-check: it can return a row that is reparented
212/// and committed away before the caller's later DELETE/decrement run,
213/// decrementing the wrong parent's counter cache for a row that was never
214/// actually removed. Used only when a counter-cache decrement needs the ids
215/// back afterwards — see [`lock_dependent_children_for_update`] for the
216/// id-free variant, and [`dependent_delete_all`]'s doc comment for why the
217/// `ORDER BY id` acquisition order matters even with no counter caches.
218#[cfg(feature = "db")]
219async fn dependent_child_ids_for_update(
220    conn: &mut crate::db::RuntimeConnection,
221    table: &str,
222    fk_column: &str,
223    parent_id: i64,
224) -> crate::AutumnResult<Vec<i64>> {
225    use diesel_async::RunQueryDsl;
226
227    #[derive(diesel::QueryableByName)]
228    struct ChildId {
229        #[diesel(sql_type = diesel::sql_types::BigInt)]
230        id: i64,
231    }
232
233    let for_update: &str = crate::backend_select! {
234        pg => { " FOR UPDATE" },
235        sqlite => { "" },
236    };
237
238    Ok(diesel::sql_query(format!(
239        "SELECT id FROM \"{table}\" WHERE \"{fk_column}\" = $1 ORDER BY id{for_update}"
240    ))
241    .bind::<diesel::sql_types::BigInt, _>(parent_id)
242    .load::<ChildId>(conn)
243    .await
244    .map_err(crate::AutumnError::from)?
245    .into_iter()
246    .map(|row| row.id)
247    .collect())
248}
249
250/// Take the same `FOR UPDATE`, ascending-id locks [`dependent_child_ids_for_update`]
251/// does, without transmitting the ids back to the client at all.
252///
253/// Codex review, PR #2647: [`dependent_delete_all`]'s deterministic pre-lock
254/// only needs ids at all when there are counter caches to decrement; a
255/// cache-free cascade needs the LOCKS (for deadlock-free ordering against a
256/// concurrent cascade through a different foreign key) but never reads an id
257/// back. An earlier revision selected `id` and called `execute()` instead of
258/// `load()`, which does stop the driver from materializing a `Vec` — but
259/// `execute()` still receives every row Postgres sends: the server has no
260/// notion of a client that discards results, so a naked `SELECT id ... FOR
261/// UPDATE` still puts one `DataRow` per matching child on the wire, an O(rows)
262/// *network* cost even with O(1) heap use. Wrapping the same locking `SELECT`
263/// in an outer `count(*)` keeps the fix: `FOR UPDATE` is legal on the
264/// unaggregated inner query (locking every row it touches, in the same
265/// `ORDER BY id` acquisition order as before), and only the outer query's
266/// single count row ever reaches the client — O(1) statements, O(1) memory,
267/// O(1) network, for a `delete_all`/leaf-`destroy` cascade of any fan-out.
268#[cfg(feature = "db")]
269async fn lock_dependent_children_for_update(
270    conn: &mut crate::db::RuntimeConnection,
271    table: &str,
272    fk_column: &str,
273    parent_id: i64,
274) -> crate::AutumnResult<()> {
275    use diesel_async::RunQueryDsl;
276
277    let for_update: &str = crate::backend_select! {
278        pg => { " FOR UPDATE" },
279        sqlite => { "" },
280    };
281
282    diesel::sql_query(format!(
283        "SELECT count(*) FROM (SELECT id FROM \"{table}\" WHERE \"{fk_column}\" = $1 \
284         ORDER BY id{for_update}) AS __autumn_locked"
285    ))
286    .bind::<diesel::sql_types::BigInt, _>(parent_id)
287    .execute(conn)
288    .await
289    .map_err(crate::AutumnError::from)?;
290    Ok(())
291}
292
293/// Bulk-delete every child row pointing at `parent_id`.
294///
295/// #1325: same shape as [`dependent_nullify`] — the rows are about to go, so
296/// their counter-cached parents are decremented first, across every spec.
297///
298/// Codex review, PR #2647: an ordered pre-lock now runs unconditionally, not
299/// only when `has_counter_caches` — without it, two concurrent cascades
300/// hitting an overlapping child set through *different* foreign keys (a
301/// diamond: the same child table `dependent(..., on_delete = destroy)` of two
302/// different parents) would each fall straight through to a plan-dependent-
303/// order bulk `DELETE`, and two deletes locking the same overlapping rows in
304/// opposite orders can deadlock, aborting one parent's cascade. The pre-lock,
305/// taken in the same `ORDER BY id` every concurrent caller uses, makes the
306/// acquisition order deterministic regardless of which foreign key drove the
307/// scan — matching the per-row Destroy loop's existing guarantee. Which
308/// pre-lock variant runs depends on whether the ids are needed afterwards:
309/// [`dependent_child_ids_for_update`] when a counter-cache decrement will read
310/// them back, [`lock_dependent_children_for_update`] otherwise, so a cache-free
311/// cascade over a huge fan-out never materializes a child id it doesn't need.
312/// Either way this stays O(1) statements: one extra locked `SELECT`, not one
313/// per row.
314///
315/// This is a runtime support function for code generated by Autumn proc macros.
316/// It is semver-exempt; do not call it directly.
317///
318/// # Errors
319///
320/// Returns the database error if the id resolution, the counter-cache
321/// decrement, or the DELETE fails.
322#[cfg(feature = "db")]
323#[doc(hidden)]
324pub async fn dependent_delete_all<M: 'static>(
325    conn: &mut crate::db::RuntimeConnection,
326    table: &str,
327    specs: &[CounterCacheSpec<M>],
328    has_counter_caches: bool,
329    fk_column: &str,
330    parent_id: i64,
331) -> crate::AutumnResult<()> {
332    use diesel_async::RunQueryDsl;
333
334    crate::ledger::refuse_out_of_band_write(table, "dependent delete_all")?;
335    if has_counter_caches {
336        // The id selection below row-locks the children before the
337        // counter-cache hook can take the lock that serializes mutations on
338        // a table with a leg onto itself, so take it here first (see
339        // `counter_cache_serialize_self_referential`).
340        counter_cache_serialize_self_referential(conn, specs).await?;
341        let ids = dependent_child_ids_for_update(conn, table, fk_column, parent_id).await?;
342        counter_cache_before_delete_many(conn, specs, &ids).await?;
343    } else {
344        lock_dependent_children_for_update(conn, table, fk_column, parent_id).await?;
345    }
346
347    diesel::sql_query(format!(
348        "DELETE FROM \"{table}\" WHERE \"{fk_column}\" = $1"
349    ))
350    .bind::<diesel::sql_types::BigInt, _>(parent_id)
351    .execute(conn)
352    .await
353    .map_err(crate::AutumnError::from)?;
354    Ok(())
355}
356
357/// Backend-portable `FOR UPDATE` seam for generated `#[repository]` CRUD.
358///
359/// Postgres pessimistic-lock reads chain `.for_update()` onto a select query to
360/// take a row lock, but diesel's `LockingClause` only implements
361/// `QueryFragment<Pg>` — the same query is not constructible against the
362/// `SQLite` backend at all, so even a *simple* repository's always-emitted lock
363/// reads would fail to compile under `--features sqlite`. Worse, diesel's
364/// `FOR UPDATE` lock marker is `pub(crate)`, so no generic extension trait in a
365/// downstream crate can name the bound `for_update()` needs — a portable seam
366/// cannot be a plain trait.
367///
368/// This macro is that seam: the `#[repository]` macro emits
369/// `::autumn_web::maybe_for_update!(<query>)` where it used to chain
370/// `<query>.for_update()`, and the returned query is still loadable
371/// (`.first(...)`/`.load(...)`), so the generated call chains compose
372/// identically on either backend. Because the `#[cfg]` that selects the
373/// expansion lives on the macro **definition**, it is evaluated in
374/// **autumn-web's** compilation (where the `sqlite` feature actually lives) —
375/// not the consumer crate's — so a downstream app needs no `sqlite` feature of
376/// its own for the right arm to be chosen.
377///
378/// - **Postgres** (default): expands to `QueryDsl::for_update(<query>)` — the
379///   `FOR UPDATE` locking clause, unchanged. Marker resolution happens at the
380///   concrete call site, so no unnameable bound is ever written.
381/// - **`SQLite`**: expands to `<query>` verbatim — `SQLite` has no
382///   `SELECT … FOR UPDATE` (it serializes writers with a database-level lock),
383///   so the row-lock read degrades to a plain read. Write-write correctness
384///   still rests on the optimistic `lock_version` check plus the pool's
385///   `busy_timeout`; see [`crate::db::RuntimeConnection`] and issue #1996 for
386///   the residual single-writer concurrency caveat.
387#[cfg(all(feature = "db", not(feature = "sqlite")))]
388#[macro_export]
389macro_rules! maybe_for_update {
390    ($query:expr $(,)?) => {
391        $crate::reexports::diesel::query_dsl::QueryDsl::for_update($query)
392    };
393}
394
395/// `SQLite` arm of [`maybe_for_update!`] — the identity.
396///
397/// See the Postgres
398/// definition for the full contract; `SQLite` has no `SELECT … FOR UPDATE`, so a
399/// pessimistic-lock read degrades to a plain read.
400#[cfg(all(feature = "db", feature = "sqlite"))]
401#[macro_export]
402macro_rules! maybe_for_update {
403    ($query:expr $(,)?) => {
404        $query
405    };
406}
407
408/// Compile-time backend block selector for generated `#[repository]`/`#[model]` CRUD.
409///
410/// Used where the two backends need *structurally different* code (not just a
411/// swapped receiver), e.g. Postgres multi-row batch insert vs. the `SQLite`
412/// per-row loop, or the batched `ON CONFLICT` upsert vs. `SQLite`'s per-row
413/// upsert.
414///
415/// The `#[cfg]` selecting the arm lives on the macro **definition**, so it is
416/// evaluated in **autumn-web's** compilation (where the `sqlite` feature lives)
417/// — the un-selected arm's tokens are never even type-checked, which is what
418/// lets each arm use backend-specific diesel fragments (Postgres `DEFAULT`-keyword
419/// batch inserts, `SQLite` per-row `RETURNING`) that would not compile against
420/// the other backend.
421///
422/// Expands to the chosen block as an expression:
423/// `::autumn_web::backend_select! { pg => { … }, sqlite => { … } }`.
424#[cfg(all(feature = "db", not(feature = "sqlite")))]
425#[macro_export]
426macro_rules! backend_select {
427    (pg => $pg:block, sqlite => $sqlite:block $(,)?) => {
428        $pg
429    };
430}
431
432/// `SQLite` arm of [`backend_select!`]. See the Postgres definition for the
433/// contract.
434#[cfg(all(feature = "db", feature = "sqlite"))]
435#[macro_export]
436macro_rules! backend_select {
437    (pg => $pg:block, sqlite => $sqlite:block $(,)?) => {
438        $sqlite
439    };
440}
441
442/// Take the same transaction-scoped advisory lock the `position` (issue
443/// #1358) insert-assign/delete-compact triggers take, from generated
444/// `move_to`.
445///
446/// Closes a deadlock where `move_to`'s `SELECT ... FOR UPDATE ORDER BY id`
447/// row locks and a concurrent compaction trigger's `UPDATE ... WHERE
448/// position > $1` (which Postgres may satisfy via the `(scope, position)`
449/// index, i.e. **not** in `id` order) can lock the same rows in different
450/// orders.
451///
452/// `lock_name` is the literal `"{table}_{position}_assign"` string baked
453/// into the migration's trigger SQL (see
454/// `autumn-cli`'s `position_triggers_up_sql_for`); `scope` is the row's
455/// scope column value (`Some`) or `None` for an unscoped position column —
456/// mirroring the trigger's own `hashtext(NEW."{scope}"::text)` / literal
457/// `0` key2 exactly, so `move_to` and the triggers contend on the *same*
458/// advisory lock key and fully serialize against each other.
459///
460/// **Postgres**: takes `pg_advisory_xact_lock(hashtext(lock_name),
461/// hashtext(scope::text))` (or key2 `0` when unscoped) — released
462/// automatically at transaction end.
463/// **`SQLite`**: a no-op — `SQLite` has no advisory locks and no trigger
464/// counterpart takes one either; write-write correctness there rests on
465/// the database-level write lock `scoped_immediate_transaction` already
466/// acquires via `BEGIN IMMEDIATE`.
467///
468/// # Errors
469///
470/// Returns the underlying [`diesel::result::Error`] if the
471/// `pg_advisory_xact_lock` query fails (e.g. the connection was lost).
472#[cfg(all(feature = "db", not(feature = "sqlite")))]
473pub async fn position_advisory_lock(
474    conn: &mut crate::db::RuntimeConnection,
475    lock_name: &str,
476    scope: ::core::option::Option<i64>,
477) -> ::std::result::Result<(), diesel::result::Error> {
478    use crate::reexports::diesel_async::RunQueryDsl as _;
479    match scope {
480        ::core::option::Option::Some(scope_value) => {
481            diesel::sql_query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))")
482                .bind::<diesel::sql_types::Text, _>(lock_name)
483                .bind::<diesel::sql_types::Text, _>(scope_value.to_string())
484                .execute(conn)
485                .await?;
486        }
487        ::core::option::Option::None => {
488            diesel::sql_query("SELECT pg_advisory_xact_lock(hashtext($1), 0)")
489                .bind::<diesel::sql_types::Text, _>(lock_name)
490                .execute(conn)
491                .await?;
492        }
493    }
494    Ok(())
495}
496
497/// `SQLite` arm of [`position_advisory_lock`] — a no-op. See the Postgres
498/// definition for the full contract.
499///
500/// # Errors
501///
502/// Never returns `Err` — kept `Result`-returning to match the Postgres
503/// arm's signature, since both are called from the same generated
504/// `move_to` body regardless of backend.
505#[cfg(all(feature = "db", feature = "sqlite"))]
506pub async fn position_advisory_lock(
507    _conn: &mut crate::db::RuntimeConnection,
508    _lock_name: &str,
509    _scope: ::core::option::Option<i64>,
510) -> ::std::result::Result<(), diesel::result::Error> {
511    Ok(())
512}
513
514/// Where a generated repository routes its read-only methods (`find_by_id`,
515/// `find_all`, `count`, `paginate`, `cursor_page`, derived `find_by_*`,
516/// full-text-search reads, …).
517///
518/// The route is snapshotted from [`crate::AppState`] when the repository is
519/// extracted, so every read within a request sees one consistent decision.
520/// Mutating methods (`save`, `update`, `delete_by_id`, bulk writes) always
521/// use the primary pool regardless of this route, as do pessimistic-lock
522/// reads (`with_lock`) and reads running on an explicit transaction
523/// connection.
524#[cfg(feature = "db")]
525#[derive(Clone)]
526pub enum ReadRoute {
527    /// Reads use the primary/write pool: no replica is configured, the
528    /// repository was declared with `#[repository(..., primary_reads)]`, or
529    /// the caller pinned this instance with `on_primary()`.
530    Primary,
531    /// Reads use this read-role pool snapshot — the replica when healthy,
532    /// or the primary when the replica is unready and the
533    /// [`ReplicaFallback::Primary`](crate::config::ReplicaFallback) policy
534    /// applies.
535    ReadPool(diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>),
536    /// A replica is configured but currently unready, and the
537    /// [`ReplicaFallback::FailReadiness`](crate::config::ReplicaFallback)
538    /// policy forbids falling back to the primary.
539    ///
540    /// Generated reads fail
541    /// fast with `503 Service Unavailable` instead of silently serving
542    /// from the wrong role.
543    Unavailable,
544}
545
546#[cfg(feature = "db")]
547impl ReadRoute {
548    /// Snapshot the read-routing decision for one request from the app
549    /// state, mirroring [`crate::AppState::read_pool`] semantics.
550    #[must_use]
551    pub fn from_state(state: &crate::AppState) -> Self {
552        if state.replica_pool().is_some() {
553            state
554                .read_pool()
555                .map_or(Self::Unavailable, |pool| Self::ReadPool(pool.clone()))
556        } else {
557            Self::Primary
558        }
559    }
560}
561
562#[cfg(feature = "db")]
563impl std::fmt::Debug for ReadRoute {
564    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565        match self {
566            Self::Primary => f.write_str("ReadRoute::Primary"),
567            Self::ReadPool(pool) => {
568                write!(f, "ReadRoute::ReadPool(max={})", pool.status().max_size)
569            }
570            Self::Unavailable => f.write_str("ReadRoute::Unavailable"),
571        }
572    }
573}
574
575/// Upper bound on bound parameters in one prepared statement, per backend —
576/// used by generated bulk-write CRUD to size insert/update chunks so a batch
577/// never exceeds the driver's placeholder limit.
578///
579/// Postgres caps a statement at 65535 (`u16`) parameters; `SQLite`'s default
580/// `SQLITE_MAX_VARIABLE_NUMBER` is 32766 (since 3.32.0). The generated code
581/// divides this by the per-row column count to pick a chunk size, so the
582/// value tracks the active backend via the same `sqlite` feature that flips
583/// [`crate::db::RuntimeConnection`]. (The `SQLite` write path also inserts
584/// row-by-row, so this bound is a conservative belt-and-suspenders there.)
585#[cfg(not(feature = "sqlite"))]
586pub const MAX_BIND_PARAMS: usize = 65535;
587/// See [`MAX_BIND_PARAMS`] (Postgres). `SQLite`'s default
588/// `SQLITE_MAX_VARIABLE_NUMBER` is 32766.
589#[cfg(feature = "sqlite")]
590pub const MAX_BIND_PARAMS: usize = 32766;
591
592/// Typed errors returned by generated repository methods.
593///
594/// Distinct from [`crate::AutumnError`] so callers can match on the
595/// variant without parsing an HTTP status code.
596#[derive(Debug, Clone, Error)]
597pub enum RepositoryError {
598    /// Two writers raced on the same row.
599    ///
600    /// Returned by generated `update`/`save` methods when the
601    /// `#[lock_version]` field no longer matches the value the client
602    /// sent — meaning another replica committed a write in the meantime.
603    ///
604    /// Map this to `409 Conflict` via [`crate::AutumnError::conflict`].
605    #[error(
606        "optimistic lock conflict on record {id}: \
607         client expected version {expected_version}, \
608         row was already modified (actual: {actual_version:?})"
609    )]
610    Conflict {
611        /// Primary key of the contested record.
612        id: i64,
613        /// The version the client read and expected to still be current.
614        expected_version: i64,
615        /// The version actually stored when the conflict was detected,
616        /// or `None` if the row was deleted between the read and the write.
617        actual_version: Option<i64>,
618    },
619}
620
621/// How a parent's dependent children are handled when the parent is deleted.
622///
623/// Declared per association on the `#[repository]` macro via
624/// `dependent(ChildRepository, fk = "parent_fk", on_delete = <action>)`, and
625/// applied inside the parent's `delete_by_id` transaction so a parent delete
626/// never leaves orphaned child rows (issue #1369).
627///
628/// The cascade runs app-side (not via a DB `ON DELETE` constraint) so that
629/// soft-delete, lifecycle/commit hooks, and counter caches stay correct — a
630/// guarantee raw `ON DELETE CASCADE` cannot provide.
631// The hidden `Probe` variant is framework plumbing. The generated cascade matches
632// every variant in the user's crate, so the enum cannot be `#[non_exhaustive]`.
633#[allow(clippy::manual_non_exhaustive)]
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum DependentAction {
636    /// Delete each child through the child repository's delete path, honoring
637    /// the child's soft-delete mode and firing its lifecycle/commit hooks.
638    Destroy,
639    /// Bulk hard-delete every child in a single `DELETE`, skipping per-row hooks.
640    DeleteAll,
641    /// Set the child's foreign-key column to `NULL` (the child rows survive,
642    /// detached from the deleted parent).
643    Nullify,
644    /// Refuse to delete the parent while any child exists, surfacing a typed
645    /// `409 Conflict` error instead of orphaning or cascading.
646    Restrict,
647    /// Read-only walk of a `destroy` leg. Probes every `restrict` below it,
648    /// and mutates nothing. Framework plumbing, not a user-facing action.
649    #[doc(hidden)]
650    Probe,
651}
652
653/// The boxed, `Send` future a [`RuntimeDependentCascadeFn`] returns.
654///
655/// It resolves to the deferred `(topic, dom_id)` OOB delete broadcasts a single
656/// child association's cascade accumulated (empty for a non-broadcasting child).
657pub type RuntimeDependentCascadeFuture<'a> = ::std::pin::Pin<
658    ::std::boxed::Box<
659        dyn ::std::future::Future<Output = crate::AutumnResult<Vec<(String, String)>>> + Send + 'a,
660    >,
661>;
662
663/// A type-erased entry point into one child repository's
664/// `__autumn_apply_dependent_on_conn` leaf executor (#1738).
665///
666/// `#[model]` emits one of these per model-declared
667/// `#[has_many(Child, dependent = …)]` association, resolving the child
668/// repository through the `Pg{Child}Repository` naming convention. Given the
669/// parent repository's pool, its live connection/transaction, the parent id,
670/// the parent's soft-delete kind, and the two shared cascade-guard sets, it
671/// constructs the child repository and applies this one association's cascade,
672/// returning any deferred OOB delete broadcasts to publish post-commit.
673///
674/// The three guard sets serve distinct roles (Codex round-5-B + #1800 case 1):
675/// `__path` is the ACTIVE recursion stack (pushed before descending into a node's
676/// children, popped once that subtree completes) used only to break
677/// self-/mutual-referential cycles; `__deleted` is a monotonic set of every row
678/// HANDLED by the cascade — soft OR physical — used by the bulk `delete_many` root
679/// dedup / hook double-fire guard to skip a root already processed as another
680/// root's descendant (so its `before_delete` never fires twice); `__physical` is
681/// the subset of rows PHYSICALLY removed, used only by the diamond traversal
682/// revisit-skip so a hard-delete path can still physically remove a row a
683/// soft-delete path merely marked `deleted_at` on. Keeping `__deleted` and
684/// `__physical` separate is what lets the bulk root skip stay complete for
685/// soft-delete graphs while the diamond hard path is still free to run; keeping
686/// `__path` separate lets a batch process a descendant root before its ancestor
687/// without the ancestor's cascade skipping a still-referenced intermediate
688/// (immediate-FK failure).
689///
690/// All references share one lifetime so the returned future can borrow the
691/// connection and all three guard sets for exactly as long as it is awaited.
692pub type RuntimeDependentCascadeFn = for<'a> fn(
693    &'a ::diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
694    &'a mut crate::db::RuntimeConnection,
695    i64,
696    bool,
697    &'a mut ::std::collections::HashSet<(&'static str, i64)>,
698    &'a mut ::std::collections::HashSet<(&'static str, i64)>,
699    &'a mut ::std::collections::HashSet<(&'static str, i64)>,
700) -> RuntimeDependentCascadeFuture<'a>;
701
702/// One model-declared dependent-cascade association, produced at compile time
703/// by `#[model]` and consulted at run time by the parent's `delete_by_id`
704/// (#1738).
705///
706/// The `#[model]` and `#[repository]` derives are separate proc-macro
707/// invocations, so the repository macro — which owns the `delete_by_id` cascade
708/// codegen — never sees the model struct's `#[has_many]` attributes. This spec
709/// bridges the two at run time: the parent iterates
710/// [`AutumnDependents::dependents`] and drives each spec's [`cascade`] inside
711/// its transaction, reproducing exactly the cascade the repository-attribute
712/// `dependent(PgChildRepository, …)` form produces.
713///
714/// Framework plumbing; not constructed by hand.
715///
716/// [`cascade`]: RuntimeDependentSpec::cascade
717pub struct RuntimeDependentSpec {
718    /// The child foreign-key column referencing the parent id. Informational:
719    /// the [`cascade`] thunk already binds it. Mirrors the repository-attribute
720    /// `fk = "…"`.
721    ///
722    /// [`cascade`]: RuntimeDependentSpec::cascade
723    pub fk: &'static str,
724    /// What to do with the children when the parent is deleted.
725    pub action: DependentAction,
726    /// Type-erased entry into the child repository's cascade leaf executor.
727    pub cascade: RuntimeDependentCascadeFn,
728    /// Read-only twin of [`cascade`](RuntimeDependentSpec::cascade).
729    ///
730    /// Set for `destroy` only. It probes every `restrict` below the leg, so the
731    /// parent can reject a blocked delete before any hook fires.
732    pub probe: Option<RuntimeDependentCascadeFn>,
733}
734
735/// Exposes a model's runtime dependent-cascade specs to the parent
736/// repository's generated `delete_by_id` (#1738).
737///
738/// A blanket impl returns an empty slice for every type; `#[model]` emits an
739/// *inherent* `dependents()` associated function — which shadows this trait
740/// method under Rust's inherent-before-trait resolution, mirroring the
741/// [`AutumnLockVersionModelExt`] pattern — only when the model declares at
742/// least one `#[has_many(…, dependent = …)]` / `#[has_one(…, dependent = …)]`
743/// association. The generated repository calls `Model::dependents()`, so a
744/// model with no dependents (or one defined without `#[model]`) resolves to the
745/// empty blanket and keeps its exact prior delete codegen.
746///
747/// Precedence: the repository-attribute `dependent(…)` form is the explicit
748/// escape hatch (needed for child repositories that do not follow the
749/// `Pg{Child}Repository` convention, e.g. cross-crate children). When a
750/// repository declares `dependent(…)`, its compile-time specs are authoritative
751/// and the model-declared runtime specs are ignored; a repository with no
752/// `dependent(…)` drives the cascade from `Model::dependents()`.
753pub trait AutumnDependents {
754    /// The model's dependent-cascade specs, in declaration order. Defaults to
755    /// none; `#[model]` overrides via an inherent shadow when dependents exist.
756    #[must_use]
757    fn dependents() -> &'static [RuntimeDependentSpec] {
758        &[]
759    }
760
761    /// The models a delete of this model also writes, through `dependent`
762    /// associations that are not `restrict`, at depth 0 to 3. Each defaults to
763    /// none; `#[model]` shadows them with inherent consts. Read by the
764    /// agent-authority grant check.
765    #[doc(hidden)]
766    const DEPENDENT_CASCADE_ANY: bool = false;
767    #[doc(hidden)]
768    const DEPENDENT_CASCADE_0: crate::agent_authority::CascadeLevel =
769        crate::agent_authority::CascadeLevel::NONE;
770    #[doc(hidden)]
771    const DEPENDENT_CASCADE_1: crate::agent_authority::CascadeLevel =
772        crate::agent_authority::CascadeLevel::NONE;
773    #[doc(hidden)]
774    const DEPENDENT_CASCADE_2: crate::agent_authority::CascadeLevel =
775        crate::agent_authority::CascadeLevel::NONE;
776    #[doc(hidden)]
777    const DEPENDENT_CASCADE_3: crate::agent_authority::CascadeLevel =
778        crate::agent_authority::CascadeLevel::NONE;
779}
780
781// Blanket fallback — any type without an inherent `dependents()` (i.e. a model
782// with no dependent associations, or a type not built through `#[model]`)
783// resolves to the empty slice.
784impl<T: ?Sized> AutumnDependents for T {}
785
786/// Publish a batch of deferred dependent-cascade OOB *delete* broadcasts.
787///
788/// Accumulated during a `dependent = destroy` cascade over `broadcasts = true`
789/// children and published **after** the parent transaction commits (#1369).
790///
791/// Each entry is `(topic, dom_id)`; the fragment is empty and the swap is
792/// [`OobSwap::Delete`](crate::htmx::OobSwap::Delete). Deferring to post-commit
793/// means a rolled-back cascade publishes nothing, so live clients are never told
794/// to remove a child that still exists. Commit-hook children defer via the
795/// durable outbox instead and are not routed through here.
796///
797/// Framework plumbing; not a public API. No-op when the live-channel /
798/// `maud` / `htmx` features are not built in (the buffer is always empty then).
799#[cfg(all(feature = "ws", feature = "maud", feature = "htmx"))]
800#[doc(hidden)]
801pub fn publish_deferred_dependent_broadcasts(broadcasts: Vec<(String, String)>) {
802    if broadcasts.is_empty() {
803        return;
804    }
805    if let Some(channels) = crate::__private::get_global_channels() {
806        let fragment = crate::html! {};
807        // Consume the buffer so the owned (topic, dom_id) strings move straight
808        // into the publish call (no needless clone, and the `Vec` is consumed —
809        // avoiding `clippy::needless_pass_by_value`).
810        for (topic, dom_id) in broadcasts {
811            if let Err(err) = channels.broadcast().publish_oob(
812                &topic,
813                &dom_id,
814                &crate::htmx::OobSwap::Delete,
815                &fragment,
816            ) {
817                tracing::warn!(
818                    error = %err,
819                    "auto-broadcast dependent-destroy delete failed"
820                );
821            }
822        }
823    }
824}
825
826/// No-op fallback when live broadcasting is not compiled in.
827///
828/// The accumulation
829/// side only runs for `broadcasts = true` children (which require these
830/// features), so the buffer is always empty in this configuration.
831#[cfg(not(all(feature = "ws", feature = "maud", feature = "htmx")))]
832#[doc(hidden)]
833pub fn publish_deferred_dependent_broadcasts(_broadcasts: Vec<(String, String)>) {}
834
835/// Extension trait that provides a fallback `None` for model structs that do
836/// not have a `#[lock_version]` field — or that are defined manually without
837/// going through `#[model]`.
838///
839/// `#[model]` generates an *inherent* method with the same name on the model
840/// and on `UpdateModel`; inherent methods take priority over trait methods in
841/// Rust's method-resolution order.  For types without `#[lock_version]` (or
842/// without `#[model]` altogether), the trait provides the `None` fallback so
843/// the generated repository code can call these methods unconditionally.
844#[doc(hidden)]
845pub trait AutumnLockVersionModelExt {
846    fn __autumn_lock_version_actual(&self) -> Option<i64> {
847        None
848    }
849}
850
851#[doc(hidden)]
852pub trait AutumnLockVersionUpdateExt {
853    fn __autumn_lock_version_expected(&self) -> Option<i64> {
854        None
855    }
856}
857
858// Blanket impls — any type that doesn't have an inherent implementation
859// (generated by `#[model]`) falls through to these, returning `None`.
860impl<T: ?Sized> AutumnLockVersionModelExt for T {}
861impl<T: ?Sized> AutumnLockVersionUpdateExt for T {}
862
863#[doc(hidden)]
864pub trait AutumnColumnCountExt {
865    fn __autumn_column_count(&self) -> usize;
866}
867
868#[doc(hidden)]
869pub trait AutumnColumnCountSpecific {
870    fn __autumn_column_count(self) -> usize;
871}
872impl<T: AutumnColumnCountExt> AutumnColumnCountSpecific for &T {
873    fn __autumn_column_count(self) -> usize {
874        self.__autumn_column_count()
875    }
876}
877
878#[doc(hidden)]
879pub trait AutumnColumnCountFallback {
880    fn __autumn_column_count(self) -> usize;
881}
882impl<T: ?Sized> AutumnColumnCountFallback for &&T {
883    fn __autumn_column_count(self) -> usize {
884        30
885    }
886}
887
888#[doc(hidden)]
889pub trait AutumnUpsertSetExt {
890    type UpsertSet;
891    fn __autumn_upsert_set() -> Self::UpsertSet;
892}
893
894#[doc(hidden)]
895pub trait AutumnUpsertExecutionExt {
896    type Model;
897    fn __autumn_execute_upsert<'a>(
898        chunk: &'a [Self::Model],
899        tenant_id: ::core::option::Option<&'a str>,
900        conn: &'a mut crate::db::RuntimeConnection,
901    ) -> impl ::std::future::Future<
902        Output = ::core::result::Result<::std::vec::Vec<Self::Model>, ::diesel::result::Error>,
903    > + Send
904    + 'a;
905}
906
907#[doc(hidden)]
908pub trait AutumnCorrelateExt: Sized {
909    type NewModel: Sized;
910    fn __autumn_correlate_new(
911        inputs: &[Self::NewModel],
912        record: &Self,
913        matched: &mut [bool],
914    ) -> ::core::option::Option<usize>;
915
916    fn __autumn_correlate_model(
917        inputs: &[Self],
918        record: &Self,
919        matched: &mut [bool],
920    ) -> ::core::option::Option<usize>;
921}
922
923/// Extension trait to override `tenant_id` on changesets in tenant-scoped updates.
924pub trait CanSetTenantId {
925    fn set_tenant_id(&mut self, tenant_id: String);
926}
927
928/// Trait implemented by models to expose their primary key value.
929pub trait ModelPrimaryKey {
930    type IdType: ::std::fmt::Display + ::core::clone::Clone + Send + Sync + 'static;
931    fn primary_key_value(&self) -> Self::IdType;
932}
933
934/// Framework plumbing bridging a `#[repository]`-generated struct to the
935/// many-to-many (`#[has_many(Target, through = join_table)]`) mutation
936/// helpers `#[model]` generates.
937///
938/// `#[repository(Model, ...)]` implements this
939/// once, unconditionally, alongside the model's other generated impls; the
940/// `Model` associated type is what keeps `add_*`/`remove_*`/`set_*` method
941/// resolution unambiguous when more than one m2m trait is in scope (e.g. two
942/// models both associate `through` the same join table, or a model has more
943/// than one m2m association) — each mutation trait is blanket-implemented
944/// only for `M2mConnSource<Model = TheAssociationsOwner>`.
945///
946/// It also backs the `#[votable(by = ..., aggregate = sum|count)]` reaction
947/// helpers (#1362): the `{Model}Reactions` trait `#[model]` emits is
948/// blanket-implemented over `M2mConnSource<Model = TheVotableModel>`, and
949/// `react()` acquires its own connection through
950/// [`M2mConnSource::__autumn_m2m_write_conn`] exactly like an `add_*` does,
951/// while the read-only `reaction_of()` uses
952/// [`M2mConnSource::__autumn_m2m_read_conn`]. Both scope their **target**
953/// lookups with [`M2mConnSource::__autumn_m2m_tenant_scope`] when the target
954/// model carries a `tenant_id` column.
955///
956/// It also backs the `#[commentable]` helpers. They use
957/// [`M2mConnSource::__autumn_m2m_soft_delete`] for the parent check (#2284).
958///
959/// Not part of the public API; not implemented by hand.
960#[cfg(feature = "db")]
961#[doc(hidden)]
962pub trait M2mConnSource: Send + Sync {
963    /// The model whose `#[has_many(..., through = ...)]` associations (or
964    /// `#[votable]` reactions) this repository's mutation helpers operate on.
965    type Model;
966
967    /// Acquire a primary-pool connection for an `add_*`/`remove_*`/`set_*`
968    /// many-to-many mutation, or for a `#[votable]` `react()`.
969    ///
970    /// Mirrors the write-connection acquisition every
971    /// other mutating generated method uses (marks the read-your-writes pin
972    /// on success).
973    fn __autumn_m2m_write_conn(
974        &self,
975    ) -> impl ::std::future::Future<
976        Output = crate::AutumnResult<
977            diesel_async::pooled_connection::deadpool::Object<crate::db::RuntimeConnection>,
978        >,
979    > + Send;
980
981    /// Acquire a connection for a *read-only* association/reaction accessor —
982    /// currently `#[votable]`'s `reaction_of()`.
983    ///
984    /// Routes exactly like every other generated read: per the repository's
985    /// [`ReadRoute`] snapshot, so a configured replica serves it (and an
986    /// `Unavailable` route fails fast). Unlike
987    /// [`__autumn_m2m_write_conn`](M2mConnSource::__autumn_m2m_write_conn) it
988    /// does **not** mark the read-your-writes pin — it is a read, so it neither
989    /// pins subsequent reads to the primary nor promises to observe a write
990    /// this request has not yet committed. A caller that must see its own
991    /// just-committed `react()` on a replica-backed repository should read
992    /// through a `primary_reads` / `on_primary()` repository, or use the
993    /// `Reaction` value `react()` already returned.
994    fn __autumn_m2m_read_conn(
995        &self,
996    ) -> impl ::std::future::Future<
997        Output = crate::AutumnResult<
998            diesel_async::pooled_connection::deadpool::Object<crate::db::RuntimeConnection>,
999        >,
1000    > + Send;
1001
1002    /// The tenant this repository would scope its queries to, for the
1003    /// `#[votable]` reaction helpers to apply to their **target** lookups.
1004    ///
1005    /// Synchronous (it only reads the `CURRENT_TENANT` task-local) and
1006    /// deliberately three-valued, mirroring what a derived finder on the same
1007    /// repository would do:
1008    ///
1009    /// - `Ok(Some(tenant))` — a `#[repository(..., tenant_scoped)]` repository
1010    ///   used inside a tenant context: `react()` / `reaction_of()` must filter
1011    ///   the target on `tenant_id`, so a foreign-tenant target id is `NotFound`
1012    ///   (respectively `None`) before any write.
1013    /// - `Ok(None)` — a repository that is not `tenant_scoped`, or one used via
1014    ///   `across_tenants()`: no tenant predicate, exactly like its finders.
1015    /// - `Err(..)` — a `tenant_scoped` repository with no tenant context: the
1016    ///   same "no tenant context was established" failure the derived queries
1017    ///   raise, so the reaction surface fails closed rather than writing
1018    ///   unscoped.
1019    ///
1020    /// Only the codegen for a model that actually has a `tenant_id` column
1021    /// calls it; a model without one pays nothing.
1022    ///
1023    /// # Errors
1024    ///
1025    /// Returns an error when the repository is `tenant_scoped`, is not in
1026    /// `across_tenants()` mode, and no tenant context was established.
1027    fn __autumn_m2m_tenant_scope(&self) -> crate::AutumnResult<::core::option::Option<String>>;
1028
1029    /// Whether this repository is `#[repository(..., soft_delete)]`, for the
1030    /// `#[commentable]` helpers' parent check (#2284).
1031    ///
1032    /// - `Some(true)` — the helpers ignore a parent with `deleted_at` set.
1033    /// - `Some(false)` — the helpers see every parent row, as the finders do.
1034    /// - `None` (the default) — unknown. The helpers use the registry rule:
1035    ///   filter if any repository of the model soft-deletes.
1036    fn __autumn_m2m_soft_delete(&self) -> ::core::option::Option<bool> {
1037        ::core::option::Option::None
1038    }
1039}
1040
1041/// Which of the three edge mutations a `#[votable]` `react()` call performed
1042/// (#1362).
1043///
1044/// A reaction edge is a single `(reactor, target)` row in the edge table, so
1045/// every `react()` resolves to exactly one of: create it, replace its value, or
1046/// delete it. The discrimination is returned rather than inferred from
1047/// [`Reaction::value`] so a route can tell "the user changed their mind"
1048/// (`Flipped`) apart from "the user reacted for the first time" (`Inserted`)
1049/// without a second query — useful for flash messages, analytics and
1050/// notification fan-out.
1051///
1052/// # Examples
1053///
1054/// ```
1055/// use autumn_web::repository::ReactionOutcome;
1056///
1057/// // Only a signed (`aggregate = sum`) reaction can be flipped.
1058/// assert_ne!(ReactionOutcome::Inserted, ReactionOutcome::Flipped);
1059/// ```
1060///
1061/// `#[non_exhaustive]`: a future aggregate mode may add an outcome, so match
1062/// arms in downstream crates must carry a `_ => …` fallback.
1063#[cfg(feature = "db")]
1064#[non_exhaustive]
1065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1066pub enum ReactionOutcome {
1067    /// The reactor had no reaction on this target; one was created.
1068    Inserted,
1069    /// The reactor had the *opposite* reaction; its value was replaced in
1070    /// place (no second edge row is ever created).
1071    ///
1072    /// Never produced by an `aggregate = count` reaction: a count edge carries
1073    /// no value, so a repeat click can only toggle it off.
1074    Flipped,
1075    /// The reactor repeated its existing reaction, so the edge was deleted
1076    /// (toggle-off).
1077    Removed,
1078}
1079
1080/// The post-commit state a `#[votable]` `react()` call leaves behind (#1362).
1081///
1082/// `react()` toggles/flips/inserts the `(reactor, target)` edge **and**
1083/// recomputes the target's aggregate column from ground truth in the same
1084/// transaction. Everything a caller needs to re-render the reaction control is
1085/// therefore already known when the transaction commits, and is returned here —
1086/// a route never has to issue a follow-up `SELECT` for the new score or for the
1087/// viewer's own reaction.
1088///
1089/// `#[non_exhaustive]`: `react()` is the only constructor, so a later field
1090/// (say, the edge's `created_at`) is not a breaking change. Read the fields;
1091/// do not struct-literal one from outside this crate.
1092///
1093/// # Examples
1094///
1095/// ```
1096/// use autumn_web::repository::{Reaction, ReactionOutcome};
1097///
1098/// // `react(user_id, post_id, 1)` on a post whose score was 6 returns
1099/// // value `Some(1)`, aggregate `7`, outcome `Inserted`. Callers read those
1100/// // fields — `react()` is the only constructor, and the wildcard arm is what
1101/// // `#[non_exhaustive]` asks of a downstream match.
1102/// fn label(reaction: &Reaction) -> String {
1103///     match reaction.outcome {
1104///         ReactionOutcome::Removed => format!("score {}", reaction.aggregate),
1105///         _ => format!("score {} (yours: {:?})", reaction.aggregate, reaction.value),
1106///     }
1107/// }
1108/// ```
1109#[cfg(feature = "db")]
1110#[non_exhaustive]
1111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1112pub struct Reaction {
1113    /// The reactor's reaction *after* the call: `Some(value)` after an insert
1114    /// or a flip, `None` after a toggle-off.
1115    ///
1116    /// An `aggregate = count` reaction reports `Some(1)` while the membership
1117    /// row exists, so view code is mode-independent.
1118    pub value: Option<i16>,
1119    /// The target's newly persisted aggregate (`score` for `aggregate = sum`,
1120    /// `{name}_count` for `aggregate = count`) — ground truth as of this
1121    /// transaction's commit, not an accumulated delta.
1122    pub aggregate: i64,
1123    /// Which edge mutation ran.
1124    pub outcome: ReactionOutcome,
1125}
1126
1127#[cfg(feature = "db")]
1128impl Reaction {
1129    /// Framework plumbing: the constructor `#[votable]`'s generated `react()`
1130    /// uses.
1131    ///
1132    /// `Reaction` is `#[non_exhaustive]`, so the generated code — which expands
1133    /// in the *application's* crate — cannot write a struct literal. Not a
1134    /// public API; call `react()` instead.
1135    #[doc(hidden)]
1136    #[must_use]
1137    pub const fn __new(value: Option<i16>, aggregate: i64, outcome: ReactionOutcome) -> Self {
1138        Self {
1139            value,
1140            aggregate,
1141            outcome,
1142        }
1143    }
1144}
1145
1146/// Metadata trait implemented for model structs to expose FTS configuration.
1147pub trait AutumnSearchableModel {
1148    const IS_SEARCHABLE: bool;
1149    const SEARCH_LANGUAGE: &'static str;
1150    const SEARCH_FIELDS: &'static [(&'static str, char)];
1151}
1152
1153/// Build a fail-closed `SQLite` FTS5 `MATCH` query string from raw user input
1154/// (issue #1910).
1155///
1156/// `SQLite` FTS5's `MATCH` right-hand operand is a query *language*, not a plain
1157/// string: bare words are terms, but `AND` / `OR` / `NOT` / `NEAR`, a `col:`
1158/// prefix, `*` (prefix), `^` (initial-token), `(`/`)` grouping, `"` phrases and
1159/// a leading `-` are all operators. Passing raw user input straight to `MATCH`
1160/// would let a caller inject FTS5 query syntax — or, more commonly, trigger a
1161/// hard syntax error on innocuous punctuation. This helper neutralizes all of
1162/// it: the input is split on Unicode whitespace and each token is emitted as a
1163/// quoted FTS5 string literal (a `"..."` phrase), with any embedded `"` doubled
1164/// (`""`) per FTS5's own string escaping. The quoted tokens are joined with a
1165/// single space, which FTS5 reads as an implicit AND of literal phrases. Every
1166/// operator character therefore becomes part of a literal term and can never
1167/// change the query's structure (the analogue of the Postgres path's
1168/// `websearch_to_tsquery` and the old LIKE path's metacharacter escaping).
1169///
1170/// Returns `None` when the input yields no tokens (empty or whitespace-only).
1171/// The caller MUST treat `None` as an empty result set and run **no** query —
1172/// never an unfiltered scan.
1173#[must_use]
1174pub fn sqlite_fts5_match_query(input: &str) -> Option<String> {
1175    // Worst case (no embedded quotes to double): every byte kept plus the two
1176    // surrounding quotes of a single token — pre-size to avoid reallocation.
1177    let mut out = String::with_capacity(input.len() + 2);
1178    for token in input.split_whitespace() {
1179        if !out.is_empty() {
1180            out.push(' ');
1181        }
1182        out.push('"');
1183        for ch in token.chars() {
1184            if ch == '"' {
1185                // FTS5 escapes a literal double-quote inside a "..." string by
1186                // doubling it.
1187                out.push('"');
1188            }
1189            out.push(ch);
1190        }
1191        out.push('"');
1192    }
1193    if out.is_empty() { None } else { Some(out) }
1194}
1195
1196/// Derive a stable signed 64-bit advisory lock key for repository upserts.
1197///
1198/// Generated versioned repositories use this before pre-reading rows for
1199/// `upsert_many`, so concurrent generated upserts for the same table/id cannot
1200/// classify audit history from a stale missing-row snapshot.
1201#[doc(hidden)]
1202#[must_use]
1203pub fn repository_upsert_advisory_lock_key(table_name: &str, record_id: i64) -> i64 {
1204    let mut hasher = Sha256::new();
1205    hasher.update(b"repository_upsert\0");
1206    hasher.update(table_name.as_bytes());
1207    hasher.update(b"\0");
1208    hasher.update(record_id.to_be_bytes());
1209    let digest = hasher.finalize();
1210    let mut bytes = [0_u8; 8];
1211    bytes.copy_from_slice(&digest[..8]);
1212    i64::from_be_bytes(bytes)
1213}
1214
1215/// Trait for formatting repository model fields into SSE broadcast topic segments.
1216///
1217/// This provides a uniform display format for both primitive types and optional/nullable values.
1218pub trait DisplayTopicField {
1219    /// Formats the field into a string suitable for a topic name.
1220    fn to_topic_string(&self) -> String;
1221}
1222
1223impl DisplayTopicField for String {
1224    fn to_topic_string(&self) -> String {
1225        self.clone()
1226    }
1227}
1228
1229impl DisplayTopicField for &str {
1230    fn to_topic_string(&self) -> String {
1231        (*self).to_string()
1232    }
1233}
1234
1235impl DisplayTopicField for bool {
1236    fn to_topic_string(&self) -> String {
1237        self.to_string()
1238    }
1239}
1240
1241impl DisplayTopicField for char {
1242    fn to_topic_string(&self) -> String {
1243        self.to_string()
1244    }
1245}
1246
1247macro_rules! impl_display_topic_field_num {
1248    ($($t:ty),*) => {
1249        $(
1250            impl DisplayTopicField for $t {
1251                fn to_topic_string(&self) -> String {
1252                    self.to_string()
1253                }
1254            }
1255        )*
1256    };
1257}
1258
1259impl_display_topic_field_num!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64);
1260
1261impl DisplayTopicField for ::uuid::Uuid {
1262    fn to_topic_string(&self) -> String {
1263        self.to_string()
1264    }
1265}
1266
1267impl DisplayTopicField for ::chrono::NaiveDateTime {
1268    fn to_topic_string(&self) -> String {
1269        self.to_string()
1270    }
1271}
1272
1273impl DisplayTopicField for ::chrono::NaiveDate {
1274    fn to_topic_string(&self) -> String {
1275        self.to_string()
1276    }
1277}
1278
1279impl DisplayTopicField for ::chrono::NaiveTime {
1280    fn to_topic_string(&self) -> String {
1281        self.to_string()
1282    }
1283}
1284
1285impl<T> DisplayTopicField for ::chrono::DateTime<T>
1286where
1287    T: ::chrono::TimeZone,
1288    T::Offset: std::fmt::Display,
1289{
1290    fn to_topic_string(&self) -> String {
1291        self.to_string()
1292    }
1293}
1294
1295impl<T: DisplayTopicField> DisplayTopicField for Option<T> {
1296    fn to_topic_string(&self) -> String {
1297        self.as_ref()
1298            .map_or_else(|| "none".to_string(), DisplayTopicField::to_topic_string)
1299    }
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304    use super::*;
1305
1306    #[test]
1307    fn fts5_match_quotes_plain_terms_and_ands_them() {
1308        // A plain multi-word query becomes an implicit-AND of quoted phrases.
1309        assert_eq!(
1310            sqlite_fts5_match_query("rust web").as_deref(),
1311            Some("\"rust\" \"web\"")
1312        );
1313        assert_eq!(
1314            sqlite_fts5_match_query("hello").as_deref(),
1315            Some("\"hello\"")
1316        );
1317    }
1318
1319    #[test]
1320    fn fts5_match_neutralizes_operators_as_literals() {
1321        // FTS5 operators are quoted into literal terms — never parsed as syntax.
1322        assert_eq!(
1323            sqlite_fts5_match_query("foo OR bar").as_deref(),
1324            Some("\"foo\" \"OR\" \"bar\"")
1325        );
1326        assert_eq!(
1327            sqlite_fts5_match_query("title:secret").as_deref(),
1328            Some("\"title:secret\"")
1329        );
1330        assert_eq!(sqlite_fts5_match_query("pre*").as_deref(), Some("\"pre*\""));
1331        assert_eq!(
1332            sqlite_fts5_match_query("NEAR(a b)").as_deref(),
1333            Some("\"NEAR(a\" \"b)\"")
1334        );
1335    }
1336
1337    #[test]
1338    fn fts5_match_doubles_embedded_quotes() {
1339        // An embedded double-quote is escaped by doubling, so it can never close
1340        // the phrase early and inject trailing syntax.
1341        assert_eq!(
1342            sqlite_fts5_match_query("a\"b").as_deref(),
1343            Some("\"a\"\"b\"")
1344        );
1345        assert_eq!(
1346            sqlite_fts5_match_query("\" OR x").as_deref(),
1347            Some("\"\"\"\" \"OR\" \"x\"")
1348        );
1349    }
1350
1351    #[test]
1352    fn fts5_match_empty_input_is_none() {
1353        // Empty / whitespace-only / no-token input yields None so the caller
1354        // returns an empty page WITHOUT running any query (fail-closed).
1355        assert_eq!(sqlite_fts5_match_query(""), None);
1356        assert_eq!(sqlite_fts5_match_query("   "), None);
1357        assert_eq!(sqlite_fts5_match_query("\t\n  \r"), None);
1358    }
1359
1360    #[test]
1361    fn conflict_variant_stores_all_fields() {
1362        let err = RepositoryError::Conflict {
1363            id: 42,
1364            expected_version: 3,
1365            actual_version: Some(4),
1366        };
1367        match err {
1368            RepositoryError::Conflict {
1369                id,
1370                expected_version,
1371                actual_version,
1372            } => {
1373                assert_eq!(id, 42);
1374                assert_eq!(expected_version, 3);
1375                assert_eq!(actual_version, Some(4));
1376            }
1377        }
1378    }
1379
1380    #[test]
1381    fn conflict_with_no_actual_version() {
1382        let err = RepositoryError::Conflict {
1383            id: 1,
1384            expected_version: 0,
1385            actual_version: None,
1386        };
1387        assert!(matches!(
1388            err,
1389            RepositoryError::Conflict {
1390                actual_version: None,
1391                ..
1392            }
1393        ));
1394    }
1395
1396    #[test]
1397    fn conflict_display_includes_id_and_expected_version() {
1398        let err = RepositoryError::Conflict {
1399            id: 99,
1400            expected_version: 7,
1401            actual_version: Some(8),
1402        };
1403        let s = err.to_string();
1404        assert!(s.contains("99"), "display should include id");
1405        assert!(s.contains('7'), "display should include expected_version");
1406    }
1407
1408    #[test]
1409    fn conflict_is_clone() {
1410        let err = RepositoryError::Conflict {
1411            id: 1,
1412            expected_version: 0,
1413            actual_version: Some(1),
1414        };
1415        let cloned = err.clone();
1416        assert!(matches!(err, RepositoryError::Conflict { id: 1, .. }));
1417        assert!(matches!(cloned, RepositoryError::Conflict { id: 1, .. }));
1418    }
1419
1420    #[test]
1421    fn conflict_implements_std_error() {
1422        let err = RepositoryError::Conflict {
1423            id: 1,
1424            expected_version: 0,
1425            actual_version: None,
1426        };
1427        let _: &dyn std::error::Error = &err;
1428    }
1429
1430    #[test]
1431    fn repository_upsert_advisory_lock_key_is_stable_for_same_table_and_id() {
1432        let a = repository_upsert_advisory_lock_key("posts", 42);
1433        let b = repository_upsert_advisory_lock_key("posts", 42);
1434
1435        assert_eq!(a, b);
1436        assert_ne!(a, 0);
1437    }
1438
1439    #[test]
1440    fn repository_upsert_advisory_lock_key_separates_table_and_id() {
1441        let key = repository_upsert_advisory_lock_key("posts", 42);
1442
1443        assert_ne!(key, repository_upsert_advisory_lock_key("comments", 42));
1444        assert_ne!(key, repository_upsert_advisory_lock_key("posts", 43));
1445    }
1446}