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/// The `SQLite` name of a generated repository. `#[repository]` emits this
409/// macro after the `Pg<Trait>` struct.
410///
411/// - **`SQLite`**: `type Sqlite<Trait> = Pg<Trait>;`, so a `SQLite` app can use
412///   the name of its backend.
413/// - **Postgres**: nothing.
414#[doc(hidden)]
415#[cfg(all(feature = "db", feature = "sqlite"))]
416#[macro_export]
417macro_rules! __sqlite_repository_alias {
418    ($vis:vis $alias:ident = $target:ident) => {
419        #[doc = ::core::concat!("The `SQLite` name of [`", ::core::stringify!($target), "`].")]
420        $vis type $alias = $target;
421    };
422}
423
424/// Postgres arm of [`__sqlite_repository_alias!`]: no alias.
425#[doc(hidden)]
426#[cfg(all(feature = "db", not(feature = "sqlite")))]
427#[macro_export]
428macro_rules! __sqlite_repository_alias {
429    ($vis:vis $alias:ident = $target:ident) => {};
430}
431
432/// Compile-time backend block selector for generated `#[repository]`/`#[model]` CRUD.
433///
434/// Used where the two backends need *structurally different* code (not just a
435/// swapped receiver), e.g. Postgres multi-row batch insert vs. the `SQLite`
436/// per-row loop, or the batched `ON CONFLICT` upsert vs. `SQLite`'s per-row
437/// upsert.
438///
439/// The `#[cfg]` selecting the arm lives on the macro **definition**, so it is
440/// evaluated in **autumn-web's** compilation (where the `sqlite` feature lives)
441/// — the un-selected arm's tokens are never even type-checked, which is what
442/// lets each arm use backend-specific diesel fragments (Postgres `DEFAULT`-keyword
443/// batch inserts, `SQLite` per-row `RETURNING`) that would not compile against
444/// the other backend.
445///
446/// Expands to the chosen block as an expression:
447/// `::autumn_web::backend_select! { pg => { … }, sqlite => { … } }`.
448#[cfg(all(feature = "db", not(feature = "sqlite")))]
449#[macro_export]
450macro_rules! backend_select {
451    (pg => $pg:block, sqlite => $sqlite:block $(,)?) => {
452        $pg
453    };
454}
455
456/// `SQLite` arm of [`backend_select!`]. See the Postgres definition for the
457/// contract.
458#[cfg(all(feature = "db", feature = "sqlite"))]
459#[macro_export]
460macro_rules! backend_select {
461    (pg => $pg:block, sqlite => $sqlite:block $(,)?) => {
462        $sqlite
463    };
464}
465
466/// Take the same transaction-scoped advisory lock the `position` (issue
467/// #1358) insert-assign/delete-compact triggers take, from generated
468/// `move_to`.
469///
470/// Closes a deadlock where `move_to`'s `SELECT ... FOR UPDATE ORDER BY id`
471/// row locks and a concurrent compaction trigger's `UPDATE ... WHERE
472/// position > $1` (which Postgres may satisfy via the `(scope, position)`
473/// index, i.e. **not** in `id` order) can lock the same rows in different
474/// orders.
475///
476/// `lock_name` is the literal `"{table}_{position}_assign"` string baked
477/// into the migration's trigger SQL (see
478/// `autumn-cli`'s `position_triggers_up_sql_for`); `scope` is the row's
479/// scope column value (`Some`) or `None` for an unscoped position column —
480/// mirroring the trigger's own `hashtext(NEW."{scope}"::text)` / literal
481/// `0` key2 exactly, so `move_to` and the triggers contend on the *same*
482/// advisory lock key and fully serialize against each other.
483///
484/// **Postgres**: takes `pg_advisory_xact_lock(hashtext(lock_name),
485/// hashtext(scope::text))` (or key2 `0` when unscoped) — released
486/// automatically at transaction end.
487/// **`SQLite`**: a no-op — `SQLite` has no advisory locks and no trigger
488/// counterpart takes one either; write-write correctness there rests on
489/// the database-level write lock `scoped_immediate_transaction` already
490/// acquires via `BEGIN IMMEDIATE`.
491///
492/// # Errors
493///
494/// Returns the underlying [`diesel::result::Error`] if the
495/// `pg_advisory_xact_lock` query fails (e.g. the connection was lost).
496#[cfg(all(feature = "db", not(feature = "sqlite")))]
497pub async fn position_advisory_lock(
498    conn: &mut crate::db::RuntimeConnection,
499    lock_name: &str,
500    scope: ::core::option::Option<i64>,
501) -> ::std::result::Result<(), diesel::result::Error> {
502    use crate::reexports::diesel_async::RunQueryDsl as _;
503    match scope {
504        ::core::option::Option::Some(scope_value) => {
505            diesel::sql_query("SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))")
506                .bind::<diesel::sql_types::Text, _>(lock_name)
507                .bind::<diesel::sql_types::Text, _>(scope_value.to_string())
508                .execute(conn)
509                .await?;
510        }
511        ::core::option::Option::None => {
512            diesel::sql_query("SELECT pg_advisory_xact_lock(hashtext($1), 0)")
513                .bind::<diesel::sql_types::Text, _>(lock_name)
514                .execute(conn)
515                .await?;
516        }
517    }
518    Ok(())
519}
520
521/// `SQLite` arm of [`position_advisory_lock`] — a no-op. See the Postgres
522/// definition for the full contract.
523///
524/// # Errors
525///
526/// Never returns `Err` — kept `Result`-returning to match the Postgres
527/// arm's signature, since both are called from the same generated
528/// `move_to` body regardless of backend.
529#[cfg(all(feature = "db", feature = "sqlite"))]
530pub async fn position_advisory_lock(
531    _conn: &mut crate::db::RuntimeConnection,
532    _lock_name: &str,
533    _scope: ::core::option::Option<i64>,
534) -> ::std::result::Result<(), diesel::result::Error> {
535    Ok(())
536}
537
538/// Where a generated repository routes its read-only methods (`find_by_id`,
539/// `find_all`, `count`, `paginate`, `cursor_page`, derived `find_by_*`,
540/// full-text-search reads, …).
541///
542/// The route is snapshotted from [`crate::AppState`] when the repository is
543/// extracted, so every read within a request sees one consistent decision.
544/// Mutating methods (`save`, `update`, `delete_by_id`, bulk writes) always
545/// use the primary pool regardless of this route, as do pessimistic-lock
546/// reads (`with_lock`) and reads running on an explicit transaction
547/// connection.
548#[cfg(feature = "db")]
549#[derive(Clone)]
550pub enum ReadRoute {
551    /// Reads use the primary/write pool: no replica is configured, the
552    /// repository was declared with `#[repository(..., primary_reads)]`, or
553    /// the caller pinned this instance with `on_primary()`.
554    Primary,
555    /// Reads use this read-role pool snapshot — the replica when healthy,
556    /// or the primary when the replica is unready and the
557    /// [`ReplicaFallback::Primary`](crate::config::ReplicaFallback) policy
558    /// applies.
559    ReadPool(diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>),
560    /// A replica is configured but currently unready, and the
561    /// [`ReplicaFallback::FailReadiness`](crate::config::ReplicaFallback)
562    /// policy forbids falling back to the primary.
563    ///
564    /// Generated reads fail
565    /// fast with `503 Service Unavailable` instead of silently serving
566    /// from the wrong role.
567    Unavailable,
568}
569
570#[cfg(feature = "db")]
571impl ReadRoute {
572    /// Snapshot the read-routing decision for one request from the app
573    /// state, mirroring [`crate::AppState::read_pool`] semantics.
574    #[must_use]
575    pub fn from_state(state: &crate::AppState) -> Self {
576        if state.replica_pool().is_some() {
577            state
578                .read_pool()
579                .map_or(Self::Unavailable, |pool| Self::ReadPool(pool.clone()))
580        } else {
581            Self::Primary
582        }
583    }
584}
585
586#[cfg(feature = "db")]
587impl std::fmt::Debug for ReadRoute {
588    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
589        match self {
590            Self::Primary => f.write_str("ReadRoute::Primary"),
591            Self::ReadPool(pool) => {
592                write!(f, "ReadRoute::ReadPool(max={})", pool.status().max_size)
593            }
594            Self::Unavailable => f.write_str("ReadRoute::Unavailable"),
595        }
596    }
597}
598
599/// Upper bound on bound parameters in one prepared statement, per backend —
600/// used by generated bulk-write CRUD to size insert/update chunks so a batch
601/// never exceeds the driver's placeholder limit.
602///
603/// Postgres caps a statement at 65535 (`u16`) parameters; `SQLite`'s default
604/// `SQLITE_MAX_VARIABLE_NUMBER` is 32766 (since 3.32.0). The generated code
605/// divides this by the per-row column count to pick a chunk size, so the
606/// value tracks the active backend via the same `sqlite` feature that flips
607/// [`crate::db::RuntimeConnection`]. (The `SQLite` write path also inserts
608/// row-by-row, so this bound is a conservative belt-and-suspenders there.)
609#[cfg(not(feature = "sqlite"))]
610pub const MAX_BIND_PARAMS: usize = 65535;
611/// See [`MAX_BIND_PARAMS`] (Postgres). `SQLite`'s default
612/// `SQLITE_MAX_VARIABLE_NUMBER` is 32766.
613#[cfg(feature = "sqlite")]
614pub const MAX_BIND_PARAMS: usize = 32766;
615
616/// Typed errors returned by generated repository methods.
617///
618/// Distinct from [`crate::AutumnError`] so callers can match on the
619/// variant without parsing an HTTP status code.
620#[derive(Debug, Clone, Error)]
621pub enum RepositoryError {
622    /// Two writers raced on the same row.
623    ///
624    /// Returned by generated `update`/`save` methods when the
625    /// `#[lock_version]` field no longer matches the value the client
626    /// sent — meaning another replica committed a write in the meantime.
627    ///
628    /// Map this to `409 Conflict` via [`crate::AutumnError::conflict`].
629    #[error(
630        "optimistic lock conflict on record {id}: \
631         client expected version {expected_version}, \
632         row was already modified (actual: {actual_version:?})"
633    )]
634    Conflict {
635        /// Primary key of the contested record.
636        id: i64,
637        /// The version the client read and expected to still be current.
638        expected_version: i64,
639        /// The version actually stored when the conflict was detected,
640        /// or `None` if the row was deleted between the read and the write.
641        actual_version: Option<i64>,
642    },
643}
644
645/// How a parent's dependent children are handled when the parent is deleted.
646///
647/// Declared per association on the `#[repository]` macro via
648/// `dependent(ChildRepository, fk = "parent_fk", on_delete = <action>)`, and
649/// applied inside the parent's `delete_by_id` transaction so a parent delete
650/// never leaves orphaned child rows (issue #1369).
651///
652/// The cascade runs app-side (not via a DB `ON DELETE` constraint) so that
653/// soft-delete, lifecycle/commit hooks, and counter caches stay correct — a
654/// guarantee raw `ON DELETE CASCADE` cannot provide.
655// The hidden `Probe` variant is framework plumbing. The generated cascade matches
656// every variant in the user's crate, so the enum cannot be `#[non_exhaustive]`.
657#[allow(clippy::manual_non_exhaustive)]
658#[derive(Debug, Clone, Copy, PartialEq, Eq)]
659pub enum DependentAction {
660    /// Delete each child through the child repository's delete path, honoring
661    /// the child's soft-delete mode and firing its lifecycle/commit hooks.
662    Destroy,
663    /// Bulk hard-delete every child in a single `DELETE`, skipping per-row hooks.
664    DeleteAll,
665    /// Set the child's foreign-key column to `NULL` (the child rows survive,
666    /// detached from the deleted parent).
667    Nullify,
668    /// Refuse to delete the parent while any child exists, surfacing a typed
669    /// `409 Conflict` error instead of orphaning or cascading.
670    Restrict,
671    /// Read-only walk of a `destroy` leg. Probes every `restrict` below it,
672    /// and mutates nothing. Framework plumbing, not a user-facing action.
673    #[doc(hidden)]
674    Probe,
675}
676
677/// The boxed, `Send` future a [`RuntimeDependentCascadeFn`] returns.
678///
679/// It resolves to the deferred `(topic, dom_id)` OOB delete broadcasts a single
680/// child association's cascade accumulated (empty for a non-broadcasting child).
681pub type RuntimeDependentCascadeFuture<'a> = ::std::pin::Pin<
682    ::std::boxed::Box<
683        dyn ::std::future::Future<Output = crate::AutumnResult<Vec<(String, String)>>> + Send + 'a,
684    >,
685>;
686
687/// A type-erased entry point into one child repository's
688/// `__autumn_apply_dependent_on_conn` leaf executor (#1738).
689///
690/// `#[model]` emits one of these per model-declared
691/// `#[has_many(Child, dependent = …)]` association, resolving the child
692/// repository through the `Pg{Child}Repository` naming convention. Given the
693/// parent repository's pool, its live connection/transaction, the parent id,
694/// the parent's soft-delete kind, and the two shared cascade-guard sets, it
695/// constructs the child repository and applies this one association's cascade,
696/// returning any deferred OOB delete broadcasts to publish post-commit.
697///
698/// The three guard sets serve distinct roles (Codex round-5-B + #1800 case 1):
699/// `__path` is the ACTIVE recursion stack (pushed before descending into a node's
700/// children, popped once that subtree completes) used only to break
701/// self-/mutual-referential cycles; `__deleted` is a monotonic set of every row
702/// HANDLED by the cascade — soft OR physical — used by the bulk `delete_many` root
703/// dedup / hook double-fire guard to skip a root already processed as another
704/// root's descendant (so its `before_delete` never fires twice); `__physical` is
705/// the subset of rows PHYSICALLY removed, used only by the diamond traversal
706/// revisit-skip so a hard-delete path can still physically remove a row a
707/// soft-delete path merely marked `deleted_at` on. Keeping `__deleted` and
708/// `__physical` separate is what lets the bulk root skip stay complete for
709/// soft-delete graphs while the diamond hard path is still free to run; keeping
710/// `__path` separate lets a batch process a descendant root before its ancestor
711/// without the ancestor's cascade skipping a still-referenced intermediate
712/// (immediate-FK failure).
713///
714/// All references share one lifetime so the returned future can borrow the
715/// connection and all three guard sets for exactly as long as it is awaited.
716pub type RuntimeDependentCascadeFn = for<'a> fn(
717    &'a ::diesel_async::pooled_connection::deadpool::Pool<crate::db::RuntimeConnection>,
718    &'a mut crate::db::RuntimeConnection,
719    i64,
720    bool,
721    &'a mut ::std::collections::HashSet<(&'static str, i64)>,
722    &'a mut ::std::collections::HashSet<(&'static str, i64)>,
723    &'a mut ::std::collections::HashSet<(&'static str, i64)>,
724) -> RuntimeDependentCascadeFuture<'a>;
725
726/// One model-declared dependent-cascade association, produced at compile time
727/// by `#[model]` and consulted at run time by the parent's `delete_by_id`
728/// (#1738).
729///
730/// The `#[model]` and `#[repository]` derives are separate proc-macro
731/// invocations, so the repository macro — which owns the `delete_by_id` cascade
732/// codegen — never sees the model struct's `#[has_many]` attributes. This spec
733/// bridges the two at run time: the parent iterates
734/// [`AutumnDependents::dependents`] and drives each spec's [`cascade`] inside
735/// its transaction, reproducing exactly the cascade the repository-attribute
736/// `dependent(PgChildRepository, …)` form produces.
737///
738/// Framework plumbing; not constructed by hand.
739///
740/// [`cascade`]: RuntimeDependentSpec::cascade
741pub struct RuntimeDependentSpec {
742    /// The child foreign-key column referencing the parent id. Informational:
743    /// the [`cascade`] thunk already binds it. Mirrors the repository-attribute
744    /// `fk = "…"`.
745    ///
746    /// [`cascade`]: RuntimeDependentSpec::cascade
747    pub fk: &'static str,
748    /// What to do with the children when the parent is deleted.
749    pub action: DependentAction,
750    /// Type-erased entry into the child repository's cascade leaf executor.
751    pub cascade: RuntimeDependentCascadeFn,
752    /// Read-only twin of [`cascade`](RuntimeDependentSpec::cascade).
753    ///
754    /// Set for `destroy` only. It probes every `restrict` below the leg, so the
755    /// parent can reject a blocked delete before any hook fires.
756    pub probe: Option<RuntimeDependentCascadeFn>,
757}
758
759/// Exposes a model's runtime dependent-cascade specs to the parent
760/// repository's generated `delete_by_id` (#1738).
761///
762/// A blanket impl returns an empty slice for every type; `#[model]` emits an
763/// *inherent* `dependents()` associated function — which shadows this trait
764/// method under Rust's inherent-before-trait resolution, mirroring the
765/// [`AutumnLockVersionModelExt`] pattern — only when the model declares at
766/// least one `#[has_many(…, dependent = …)]` / `#[has_one(…, dependent = …)]`
767/// association. The generated repository calls `Model::dependents()`, so a
768/// model with no dependents (or one defined without `#[model]`) resolves to the
769/// empty blanket and keeps its exact prior delete codegen.
770///
771/// Precedence: the repository-attribute `dependent(…)` form is the explicit
772/// escape hatch (needed for child repositories that do not follow the
773/// `Pg{Child}Repository` convention, e.g. cross-crate children). When a
774/// repository declares `dependent(…)`, its compile-time specs are authoritative
775/// and the model-declared runtime specs are ignored; a repository with no
776/// `dependent(…)` drives the cascade from `Model::dependents()`.
777pub trait AutumnDependents {
778    /// The model's dependent-cascade specs, in declaration order. Defaults to
779    /// none; `#[model]` overrides via an inherent shadow when dependents exist.
780    #[must_use]
781    fn dependents() -> &'static [RuntimeDependentSpec] {
782        &[]
783    }
784
785    /// The models a delete of this model also writes, through `dependent`
786    /// associations that are not `restrict`, at depth 0 to 3. Each defaults to
787    /// none; `#[model]` shadows them with inherent consts. Read by the
788    /// agent-authority grant check.
789    #[doc(hidden)]
790    const DEPENDENT_CASCADE_ANY: bool = false;
791    #[doc(hidden)]
792    const DEPENDENT_CASCADE_0: crate::agent_authority::CascadeLevel =
793        crate::agent_authority::CascadeLevel::NONE;
794    #[doc(hidden)]
795    const DEPENDENT_CASCADE_1: crate::agent_authority::CascadeLevel =
796        crate::agent_authority::CascadeLevel::NONE;
797    #[doc(hidden)]
798    const DEPENDENT_CASCADE_2: crate::agent_authority::CascadeLevel =
799        crate::agent_authority::CascadeLevel::NONE;
800    #[doc(hidden)]
801    const DEPENDENT_CASCADE_3: crate::agent_authority::CascadeLevel =
802        crate::agent_authority::CascadeLevel::NONE;
803}
804
805// Blanket fallback — any type without an inherent `dependents()` (i.e. a model
806// with no dependent associations, or a type not built through `#[model]`)
807// resolves to the empty slice.
808impl<T: ?Sized> AutumnDependents for T {}
809
810/// Publish a batch of deferred dependent-cascade OOB *delete* broadcasts.
811///
812/// Accumulated during a `dependent = destroy` cascade over `broadcasts = true`
813/// children and published **after** the parent transaction commits (#1369).
814///
815/// Each entry is `(topic, dom_id)`; the fragment is empty and the swap is
816/// [`OobSwap::Delete`](crate::htmx::OobSwap::Delete). Deferring to post-commit
817/// means a rolled-back cascade publishes nothing, so live clients are never told
818/// to remove a child that still exists. Commit-hook children defer via the
819/// durable outbox instead and are not routed through here.
820///
821/// Framework plumbing; not a public API. No-op when the live-channel /
822/// `maud` / `htmx` features are not built in (the buffer is always empty then).
823#[cfg(all(feature = "ws", feature = "maud", feature = "htmx"))]
824#[doc(hidden)]
825pub fn publish_deferred_dependent_broadcasts(broadcasts: Vec<(String, String)>) {
826    if broadcasts.is_empty() {
827        return;
828    }
829    if let Some(channels) = crate::__private::get_global_channels() {
830        let fragment = crate::html! {};
831        // Consume the buffer so the owned (topic, dom_id) strings move straight
832        // into the publish call (no needless clone, and the `Vec` is consumed —
833        // avoiding `clippy::needless_pass_by_value`).
834        for (topic, dom_id) in broadcasts {
835            if let Err(err) = channels.broadcast().publish_oob(
836                &topic,
837                &dom_id,
838                &crate::htmx::OobSwap::Delete,
839                &fragment,
840            ) {
841                tracing::warn!(
842                    error = %err,
843                    "auto-broadcast dependent-destroy delete failed"
844                );
845            }
846        }
847    }
848}
849
850/// No-op fallback when live broadcasting is not compiled in.
851///
852/// The accumulation
853/// side only runs for `broadcasts = true` children (which require these
854/// features), so the buffer is always empty in this configuration.
855#[cfg(not(all(feature = "ws", feature = "maud", feature = "htmx")))]
856#[doc(hidden)]
857pub fn publish_deferred_dependent_broadcasts(_broadcasts: Vec<(String, String)>) {}
858
859/// Extension trait that provides a fallback `None` for model structs that do
860/// not have a `#[lock_version]` field — or that are defined manually without
861/// going through `#[model]`.
862///
863/// `#[model]` generates an *inherent* method with the same name on the model
864/// and on `UpdateModel`; inherent methods take priority over trait methods in
865/// Rust's method-resolution order.  For types without `#[lock_version]` (or
866/// without `#[model]` altogether), the trait provides the `None` fallback so
867/// the generated repository code can call these methods unconditionally.
868#[doc(hidden)]
869pub trait AutumnLockVersionModelExt {
870    fn __autumn_lock_version_actual(&self) -> Option<i64> {
871        None
872    }
873}
874
875#[doc(hidden)]
876pub trait AutumnLockVersionUpdateExt {
877    fn __autumn_lock_version_expected(&self) -> Option<i64> {
878        None
879    }
880}
881
882// Blanket impls — any type that doesn't have an inherent implementation
883// (generated by `#[model]`) falls through to these, returning `None`.
884impl<T: ?Sized> AutumnLockVersionModelExt for T {}
885impl<T: ?Sized> AutumnLockVersionUpdateExt for T {}
886
887#[doc(hidden)]
888pub trait AutumnColumnCountExt {
889    fn __autumn_column_count(&self) -> usize;
890}
891
892#[doc(hidden)]
893pub trait AutumnColumnCountSpecific {
894    fn __autumn_column_count(self) -> usize;
895}
896impl<T: AutumnColumnCountExt> AutumnColumnCountSpecific for &T {
897    fn __autumn_column_count(self) -> usize {
898        self.__autumn_column_count()
899    }
900}
901
902#[doc(hidden)]
903pub trait AutumnColumnCountFallback {
904    fn __autumn_column_count(self) -> usize;
905}
906impl<T: ?Sized> AutumnColumnCountFallback for &&T {
907    fn __autumn_column_count(self) -> usize {
908        30
909    }
910}
911
912#[doc(hidden)]
913pub trait AutumnUpsertSetExt {
914    type UpsertSet;
915    fn __autumn_upsert_set() -> Self::UpsertSet;
916}
917
918#[doc(hidden)]
919pub trait AutumnUpsertExecutionExt {
920    type Model;
921    fn __autumn_execute_upsert<'a>(
922        chunk: &'a [Self::Model],
923        tenant_id: ::core::option::Option<&'a str>,
924        conn: &'a mut crate::db::RuntimeConnection,
925    ) -> impl ::std::future::Future<
926        Output = ::core::result::Result<::std::vec::Vec<Self::Model>, ::diesel::result::Error>,
927    > + Send
928    + 'a;
929}
930
931#[doc(hidden)]
932pub trait AutumnCorrelateExt: Sized {
933    type NewModel: Sized;
934    fn __autumn_correlate_new(
935        inputs: &[Self::NewModel],
936        record: &Self,
937        matched: &mut [bool],
938    ) -> ::core::option::Option<usize>;
939
940    fn __autumn_correlate_model(
941        inputs: &[Self],
942        record: &Self,
943        matched: &mut [bool],
944    ) -> ::core::option::Option<usize>;
945}
946
947/// Extension trait to override `tenant_id` on changesets in tenant-scoped updates.
948pub trait CanSetTenantId {
949    fn set_tenant_id(&mut self, tenant_id: String);
950}
951
952/// Trait implemented by models to expose their primary key value.
953pub trait ModelPrimaryKey {
954    type IdType: ::std::fmt::Display + ::core::clone::Clone + Send + Sync + 'static;
955    fn primary_key_value(&self) -> Self::IdType;
956}
957
958/// Framework plumbing bridging a `#[repository]`-generated struct to the
959/// many-to-many (`#[has_many(Target, through = join_table)]`) mutation
960/// helpers `#[model]` generates.
961///
962/// `#[repository(Model, ...)]` implements this
963/// once, unconditionally, alongside the model's other generated impls; the
964/// `Model` associated type is what keeps `add_*`/`remove_*`/`set_*` method
965/// resolution unambiguous when more than one m2m trait is in scope (e.g. two
966/// models both associate `through` the same join table, or a model has more
967/// than one m2m association) — each mutation trait is blanket-implemented
968/// only for `M2mConnSource<Model = TheAssociationsOwner>`.
969///
970/// It also backs the `#[votable(by = ..., aggregate = sum|count)]` reaction
971/// helpers (#1362): the `{Model}Reactions` trait `#[model]` emits is
972/// blanket-implemented over `M2mConnSource<Model = TheVotableModel>`, and
973/// `react()` acquires its own connection through
974/// [`M2mConnSource::__autumn_m2m_write_conn`] exactly like an `add_*` does,
975/// while the read-only `reaction_of()` uses
976/// [`M2mConnSource::__autumn_m2m_read_conn`]. Both scope their **target**
977/// lookups with [`M2mConnSource::__autumn_m2m_tenant_scope`] when the target
978/// model carries a `tenant_id` column.
979///
980/// It also backs the `#[commentable]` helpers. They use
981/// [`M2mConnSource::__autumn_m2m_soft_delete`] for the parent check (#2284).
982///
983/// Not part of the public API; not implemented by hand.
984#[cfg(feature = "db")]
985#[doc(hidden)]
986pub trait M2mConnSource: Send + Sync {
987    /// The model whose `#[has_many(..., through = ...)]` associations (or
988    /// `#[votable]` reactions) this repository's mutation helpers operate on.
989    type Model;
990
991    /// Acquire a primary-pool connection for an `add_*`/`remove_*`/`set_*`
992    /// many-to-many mutation, or for a `#[votable]` `react()`.
993    ///
994    /// Mirrors the write-connection acquisition every
995    /// other mutating generated method uses (marks the read-your-writes pin
996    /// on success).
997    fn __autumn_m2m_write_conn(
998        &self,
999    ) -> impl ::std::future::Future<
1000        Output = crate::AutumnResult<
1001            diesel_async::pooled_connection::deadpool::Object<crate::db::RuntimeConnection>,
1002        >,
1003    > + Send;
1004
1005    /// Acquire a connection for a *read-only* association/reaction accessor —
1006    /// currently `#[votable]`'s `reaction_of()`.
1007    ///
1008    /// Routes exactly like every other generated read: per the repository's
1009    /// [`ReadRoute`] snapshot, so a configured replica serves it (and an
1010    /// `Unavailable` route fails fast). Unlike
1011    /// [`__autumn_m2m_write_conn`](M2mConnSource::__autumn_m2m_write_conn) it
1012    /// does **not** mark the read-your-writes pin — it is a read, so it neither
1013    /// pins subsequent reads to the primary nor promises to observe a write
1014    /// this request has not yet committed. A caller that must see its own
1015    /// just-committed `react()` on a replica-backed repository should read
1016    /// through a `primary_reads` / `on_primary()` repository, or use the
1017    /// `Reaction` value `react()` already returned.
1018    fn __autumn_m2m_read_conn(
1019        &self,
1020    ) -> impl ::std::future::Future<
1021        Output = crate::AutumnResult<
1022            diesel_async::pooled_connection::deadpool::Object<crate::db::RuntimeConnection>,
1023        >,
1024    > + Send;
1025
1026    /// The tenant this repository would scope its queries to, for the
1027    /// `#[votable]` reaction helpers to apply to their **target** lookups.
1028    ///
1029    /// Synchronous (it only reads the `CURRENT_TENANT` task-local) and
1030    /// deliberately three-valued, mirroring what a derived finder on the same
1031    /// repository would do:
1032    ///
1033    /// - `Ok(Some(tenant))` — a `#[repository(..., tenant_scoped)]` repository
1034    ///   used inside a tenant context: `react()` / `reaction_of()` must filter
1035    ///   the target on `tenant_id`, so a foreign-tenant target id is `NotFound`
1036    ///   (respectively `None`) before any write.
1037    /// - `Ok(None)` — a repository that is not `tenant_scoped`, or one used via
1038    ///   `across_tenants()`: no tenant predicate, exactly like its finders.
1039    /// - `Err(..)` — a `tenant_scoped` repository with no tenant context: the
1040    ///   same "no tenant context was established" failure the derived queries
1041    ///   raise, so the reaction surface fails closed rather than writing
1042    ///   unscoped.
1043    ///
1044    /// Only the codegen for a model that actually has a `tenant_id` column
1045    /// calls it; a model without one pays nothing.
1046    ///
1047    /// # Errors
1048    ///
1049    /// Returns an error when the repository is `tenant_scoped`, is not in
1050    /// `across_tenants()` mode, and no tenant context was established.
1051    fn __autumn_m2m_tenant_scope(&self) -> crate::AutumnResult<::core::option::Option<String>>;
1052
1053    /// Whether this repository is `#[repository(..., soft_delete)]`, for the
1054    /// `#[commentable]` helpers' parent check (#2284).
1055    ///
1056    /// - `Some(true)` — the helpers ignore a parent with `deleted_at` set.
1057    /// - `Some(false)` — the helpers see every parent row, as the finders do.
1058    /// - `None` (the default) — unknown. The helpers use the registry rule:
1059    ///   filter if any repository of the model soft-deletes.
1060    fn __autumn_m2m_soft_delete(&self) -> ::core::option::Option<bool> {
1061        ::core::option::Option::None
1062    }
1063}
1064
1065/// Which of the three edge mutations a `#[votable]` `react()` call performed
1066/// (#1362).
1067///
1068/// A reaction edge is a single `(reactor, target)` row in the edge table, so
1069/// every `react()` resolves to exactly one of: create it, replace its value, or
1070/// delete it. The discrimination is returned rather than inferred from
1071/// [`Reaction::value`] so a route can tell "the user changed their mind"
1072/// (`Flipped`) apart from "the user reacted for the first time" (`Inserted`)
1073/// without a second query — useful for flash messages, analytics and
1074/// notification fan-out.
1075///
1076/// # Examples
1077///
1078/// ```
1079/// use autumn_web::repository::ReactionOutcome;
1080///
1081/// // Only a signed (`aggregate = sum`) reaction can be flipped.
1082/// assert_ne!(ReactionOutcome::Inserted, ReactionOutcome::Flipped);
1083/// ```
1084///
1085/// `#[non_exhaustive]`: a future aggregate mode may add an outcome, so match
1086/// arms in downstream crates must carry a `_ => …` fallback.
1087#[cfg(feature = "db")]
1088#[non_exhaustive]
1089#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1090pub enum ReactionOutcome {
1091    /// The reactor had no reaction on this target; one was created.
1092    Inserted,
1093    /// The reactor had the *opposite* reaction; its value was replaced in
1094    /// place (no second edge row is ever created).
1095    ///
1096    /// Never produced by an `aggregate = count` reaction: a count edge carries
1097    /// no value, so a repeat click can only toggle it off.
1098    Flipped,
1099    /// The reactor repeated its existing reaction, so the edge was deleted
1100    /// (toggle-off).
1101    Removed,
1102}
1103
1104/// The post-commit state a `#[votable]` `react()` call leaves behind (#1362).
1105///
1106/// `react()` toggles/flips/inserts the `(reactor, target)` edge **and**
1107/// recomputes the target's aggregate column from ground truth in the same
1108/// transaction. Everything a caller needs to re-render the reaction control is
1109/// therefore already known when the transaction commits, and is returned here —
1110/// a route never has to issue a follow-up `SELECT` for the new score or for the
1111/// viewer's own reaction.
1112///
1113/// `#[non_exhaustive]`: `react()` is the only constructor, so a later field
1114/// (say, the edge's `created_at`) is not a breaking change. Read the fields;
1115/// do not struct-literal one from outside this crate.
1116///
1117/// # Examples
1118///
1119/// ```
1120/// use autumn_web::repository::{Reaction, ReactionOutcome};
1121///
1122/// // `react(user_id, post_id, 1)` on a post whose score was 6 returns
1123/// // value `Some(1)`, aggregate `7`, outcome `Inserted`. Callers read those
1124/// // fields — `react()` is the only constructor, and the wildcard arm is what
1125/// // `#[non_exhaustive]` asks of a downstream match.
1126/// fn label(reaction: &Reaction) -> String {
1127///     match reaction.outcome {
1128///         ReactionOutcome::Removed => format!("score {}", reaction.aggregate),
1129///         _ => format!("score {} (yours: {:?})", reaction.aggregate, reaction.value),
1130///     }
1131/// }
1132/// ```
1133#[cfg(feature = "db")]
1134#[non_exhaustive]
1135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1136pub struct Reaction {
1137    /// The reactor's reaction *after* the call: `Some(value)` after an insert
1138    /// or a flip, `None` after a toggle-off.
1139    ///
1140    /// An `aggregate = count` reaction reports `Some(1)` while the membership
1141    /// row exists, so view code is mode-independent.
1142    pub value: Option<i16>,
1143    /// The target's newly persisted aggregate (`score` for `aggregate = sum`,
1144    /// `{name}_count` for `aggregate = count`) — ground truth as of this
1145    /// transaction's commit, not an accumulated delta.
1146    pub aggregate: i64,
1147    /// Which edge mutation ran.
1148    pub outcome: ReactionOutcome,
1149}
1150
1151#[cfg(feature = "db")]
1152impl Reaction {
1153    /// Framework plumbing: the constructor `#[votable]`'s generated `react()`
1154    /// uses.
1155    ///
1156    /// `Reaction` is `#[non_exhaustive]`, so the generated code — which expands
1157    /// in the *application's* crate — cannot write a struct literal. Not a
1158    /// public API; call `react()` instead.
1159    #[doc(hidden)]
1160    #[must_use]
1161    pub const fn __new(value: Option<i16>, aggregate: i64, outcome: ReactionOutcome) -> Self {
1162        Self {
1163            value,
1164            aggregate,
1165            outcome,
1166        }
1167    }
1168}
1169
1170/// Metadata trait implemented for model structs to expose FTS configuration.
1171pub trait AutumnSearchableModel {
1172    const IS_SEARCHABLE: bool;
1173    const SEARCH_LANGUAGE: &'static str;
1174    const SEARCH_FIELDS: &'static [(&'static str, char)];
1175}
1176
1177/// Build a fail-closed `SQLite` FTS5 `MATCH` query string from raw user input
1178/// (issue #1910).
1179///
1180/// `SQLite` FTS5's `MATCH` right-hand operand is a query *language*, not a plain
1181/// string: bare words are terms, but `AND` / `OR` / `NOT` / `NEAR`, a `col:`
1182/// prefix, `*` (prefix), `^` (initial-token), `(`/`)` grouping, `"` phrases and
1183/// a leading `-` are all operators. Passing raw user input straight to `MATCH`
1184/// would let a caller inject FTS5 query syntax — or, more commonly, trigger a
1185/// hard syntax error on innocuous punctuation. This helper neutralizes all of
1186/// it: the input is split on Unicode whitespace and each token is emitted as a
1187/// quoted FTS5 string literal (a `"..."` phrase), with any embedded `"` doubled
1188/// (`""`) per FTS5's own string escaping. The quoted tokens are joined with a
1189/// single space, which FTS5 reads as an implicit AND of literal phrases. Every
1190/// operator character therefore becomes part of a literal term and can never
1191/// change the query's structure (the analogue of the Postgres path's
1192/// `websearch_to_tsquery` and the old LIKE path's metacharacter escaping).
1193///
1194/// Returns `None` when the input yields no tokens (empty or whitespace-only).
1195/// The caller MUST treat `None` as an empty result set and run **no** query —
1196/// never an unfiltered scan.
1197#[must_use]
1198pub fn sqlite_fts5_match_query(input: &str) -> Option<String> {
1199    // Worst case (no embedded quotes to double): every byte kept plus the two
1200    // surrounding quotes of a single token — pre-size to avoid reallocation.
1201    let mut out = String::with_capacity(input.len() + 2);
1202    for token in input.split_whitespace() {
1203        if !out.is_empty() {
1204            out.push(' ');
1205        }
1206        out.push('"');
1207        for ch in token.chars() {
1208            if ch == '"' {
1209                // FTS5 escapes a literal double-quote inside a "..." string by
1210                // doubling it.
1211                out.push('"');
1212            }
1213            out.push(ch);
1214        }
1215        out.push('"');
1216    }
1217    if out.is_empty() { None } else { Some(out) }
1218}
1219
1220/// Derive a stable signed 64-bit advisory lock key for repository upserts.
1221///
1222/// Generated versioned repositories use this before pre-reading rows for
1223/// `upsert_many`, so concurrent generated upserts for the same table/id cannot
1224/// classify audit history from a stale missing-row snapshot.
1225#[doc(hidden)]
1226#[must_use]
1227pub fn repository_upsert_advisory_lock_key(table_name: &str, record_id: i64) -> i64 {
1228    let mut hasher = Sha256::new();
1229    hasher.update(b"repository_upsert\0");
1230    hasher.update(table_name.as_bytes());
1231    hasher.update(b"\0");
1232    hasher.update(record_id.to_be_bytes());
1233    let digest = hasher.finalize();
1234    let mut bytes = [0_u8; 8];
1235    bytes.copy_from_slice(&digest[..8]);
1236    i64::from_be_bytes(bytes)
1237}
1238
1239/// Trait for formatting repository model fields into SSE broadcast topic segments.
1240///
1241/// This provides a uniform display format for both primitive types and optional/nullable values.
1242pub trait DisplayTopicField {
1243    /// Formats the field into a string suitable for a topic name.
1244    fn to_topic_string(&self) -> String;
1245}
1246
1247impl DisplayTopicField for String {
1248    fn to_topic_string(&self) -> String {
1249        self.clone()
1250    }
1251}
1252
1253impl DisplayTopicField for &str {
1254    fn to_topic_string(&self) -> String {
1255        (*self).to_string()
1256    }
1257}
1258
1259impl DisplayTopicField for bool {
1260    fn to_topic_string(&self) -> String {
1261        self.to_string()
1262    }
1263}
1264
1265impl DisplayTopicField for char {
1266    fn to_topic_string(&self) -> String {
1267        self.to_string()
1268    }
1269}
1270
1271macro_rules! impl_display_topic_field_num {
1272    ($($t:ty),*) => {
1273        $(
1274            impl DisplayTopicField for $t {
1275                fn to_topic_string(&self) -> String {
1276                    self.to_string()
1277                }
1278            }
1279        )*
1280    };
1281}
1282
1283impl_display_topic_field_num!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64);
1284
1285impl DisplayTopicField for ::uuid::Uuid {
1286    fn to_topic_string(&self) -> String {
1287        self.to_string()
1288    }
1289}
1290
1291impl DisplayTopicField for ::chrono::NaiveDateTime {
1292    fn to_topic_string(&self) -> String {
1293        self.to_string()
1294    }
1295}
1296
1297impl DisplayTopicField for ::chrono::NaiveDate {
1298    fn to_topic_string(&self) -> String {
1299        self.to_string()
1300    }
1301}
1302
1303impl DisplayTopicField for ::chrono::NaiveTime {
1304    fn to_topic_string(&self) -> String {
1305        self.to_string()
1306    }
1307}
1308
1309impl<T> DisplayTopicField for ::chrono::DateTime<T>
1310where
1311    T: ::chrono::TimeZone,
1312    T::Offset: std::fmt::Display,
1313{
1314    fn to_topic_string(&self) -> String {
1315        self.to_string()
1316    }
1317}
1318
1319impl<T: DisplayTopicField> DisplayTopicField for Option<T> {
1320    fn to_topic_string(&self) -> String {
1321        self.as_ref()
1322            .map_or_else(|| "none".to_string(), DisplayTopicField::to_topic_string)
1323    }
1324}
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    #[test]
1331    fn fts5_match_quotes_plain_terms_and_ands_them() {
1332        // A plain multi-word query becomes an implicit-AND of quoted phrases.
1333        assert_eq!(
1334            sqlite_fts5_match_query("rust web").as_deref(),
1335            Some("\"rust\" \"web\"")
1336        );
1337        assert_eq!(
1338            sqlite_fts5_match_query("hello").as_deref(),
1339            Some("\"hello\"")
1340        );
1341    }
1342
1343    #[test]
1344    fn fts5_match_neutralizes_operators_as_literals() {
1345        // FTS5 operators are quoted into literal terms — never parsed as syntax.
1346        assert_eq!(
1347            sqlite_fts5_match_query("foo OR bar").as_deref(),
1348            Some("\"foo\" \"OR\" \"bar\"")
1349        );
1350        assert_eq!(
1351            sqlite_fts5_match_query("title:secret").as_deref(),
1352            Some("\"title:secret\"")
1353        );
1354        assert_eq!(sqlite_fts5_match_query("pre*").as_deref(), Some("\"pre*\""));
1355        assert_eq!(
1356            sqlite_fts5_match_query("NEAR(a b)").as_deref(),
1357            Some("\"NEAR(a\" \"b)\"")
1358        );
1359    }
1360
1361    #[test]
1362    fn fts5_match_doubles_embedded_quotes() {
1363        // An embedded double-quote is escaped by doubling, so it can never close
1364        // the phrase early and inject trailing syntax.
1365        assert_eq!(
1366            sqlite_fts5_match_query("a\"b").as_deref(),
1367            Some("\"a\"\"b\"")
1368        );
1369        assert_eq!(
1370            sqlite_fts5_match_query("\" OR x").as_deref(),
1371            Some("\"\"\"\" \"OR\" \"x\"")
1372        );
1373    }
1374
1375    #[test]
1376    fn fts5_match_empty_input_is_none() {
1377        // Empty / whitespace-only / no-token input yields None so the caller
1378        // returns an empty page WITHOUT running any query (fail-closed).
1379        assert_eq!(sqlite_fts5_match_query(""), None);
1380        assert_eq!(sqlite_fts5_match_query("   "), None);
1381        assert_eq!(sqlite_fts5_match_query("\t\n  \r"), None);
1382    }
1383
1384    #[test]
1385    fn conflict_variant_stores_all_fields() {
1386        let err = RepositoryError::Conflict {
1387            id: 42,
1388            expected_version: 3,
1389            actual_version: Some(4),
1390        };
1391        match err {
1392            RepositoryError::Conflict {
1393                id,
1394                expected_version,
1395                actual_version,
1396            } => {
1397                assert_eq!(id, 42);
1398                assert_eq!(expected_version, 3);
1399                assert_eq!(actual_version, Some(4));
1400            }
1401        }
1402    }
1403
1404    #[test]
1405    fn conflict_with_no_actual_version() {
1406        let err = RepositoryError::Conflict {
1407            id: 1,
1408            expected_version: 0,
1409            actual_version: None,
1410        };
1411        assert!(matches!(
1412            err,
1413            RepositoryError::Conflict {
1414                actual_version: None,
1415                ..
1416            }
1417        ));
1418    }
1419
1420    #[test]
1421    fn conflict_display_includes_id_and_expected_version() {
1422        let err = RepositoryError::Conflict {
1423            id: 99,
1424            expected_version: 7,
1425            actual_version: Some(8),
1426        };
1427        let s = err.to_string();
1428        assert!(s.contains("99"), "display should include id");
1429        assert!(s.contains('7'), "display should include expected_version");
1430    }
1431
1432    #[test]
1433    fn conflict_is_clone() {
1434        let err = RepositoryError::Conflict {
1435            id: 1,
1436            expected_version: 0,
1437            actual_version: Some(1),
1438        };
1439        let cloned = err.clone();
1440        assert!(matches!(err, RepositoryError::Conflict { id: 1, .. }));
1441        assert!(matches!(cloned, RepositoryError::Conflict { id: 1, .. }));
1442    }
1443
1444    #[test]
1445    fn conflict_implements_std_error() {
1446        let err = RepositoryError::Conflict {
1447            id: 1,
1448            expected_version: 0,
1449            actual_version: None,
1450        };
1451        let _: &dyn std::error::Error = &err;
1452    }
1453
1454    #[test]
1455    fn repository_upsert_advisory_lock_key_is_stable_for_same_table_and_id() {
1456        let a = repository_upsert_advisory_lock_key("posts", 42);
1457        let b = repository_upsert_advisory_lock_key("posts", 42);
1458
1459        assert_eq!(a, b);
1460        assert_ne!(a, 0);
1461    }
1462
1463    #[test]
1464    fn repository_upsert_advisory_lock_key_separates_table_and_id() {
1465        let key = repository_upsert_advisory_lock_key("posts", 42);
1466
1467        assert_ne!(key, repository_upsert_advisory_lock_key("comments", 42));
1468        assert_ne!(key, repository_upsert_advisory_lock_key("posts", 43));
1469    }
1470}