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