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