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