Skip to main content

backbone_core/
service.rs

1//! Generic CRUD service — one implementation used by all entities.
2//!
3//! Generated code emits only a type alias:
4//!
5//! ```rust,ignore
6//! // Generated (was ~250 lines, now 1 line):
7//! pub type StoredFileService = GenericCrudService<
8//!     StoredFile,
9//!     CreateStoredFileDto,
10//!     UpdateStoredFileDto,
11//!     PostgresStoredFileRepository,
12//! >;
13//! ```
14//!
15//! Custom services wrap the alias with a decorator:
16//!
17//! ```rust,ignore
18//! pub struct StoredFileServiceCustom {
19//!     inner: Arc<StoredFileService>,
20//!     notifications: Arc<NotificationService>,
21//! }
22//!
23//! impl StoredFileServiceCustom {
24//!     pub async fn archive(&self, id: &str, reason: String) -> ServiceResult<StoredFile> {
25//!         let entity = self.inner.get_by_id(id).await?.ok_or(ServiceError::NotFound)?;
26//!         // custom business logic
27//!     }
28//! }
29//! ```
30
31use async_trait::async_trait;
32use std::collections::HashMap;
33use std::marker::PhantomData;
34use std::sync::Arc;
35
36use backbone_messaging::crud_event::{CrudEvent, CrudEventPublisher, EventMetadata, NoOpCrudEventPublisher};
37
38use crate::persistence::traits::{CrudRepository, PersistentEntity, RepositoryError};
39
40// ─── Service error ───────────────────────────────────────────────────────────
41
42/// Standard service-layer error type.
43#[derive(Debug, thiserror::Error)]
44pub enum ServiceError {
45    #[error("not found")]
46    NotFound,
47
48    #[error("already exists: {0}")]
49    AlreadyExists(String),
50
51    #[error("validation failed: {0}")]
52    Validation(String),
53
54    /// The write broke named rules: one violation per rule, each with the
55    /// field it concerns and a stable code. Displays as the same
56    /// `validation failed: …` sentence a plain validation error does.
57    #[error("validation failed: {}", .0.iter().map(|v| v.message.as_str()).collect::<Vec<_>>().join("; "))]
58    Violations(Vec<crate::violation::Violation>),
59
60    #[error("repository error: {0}")]
61    Repository(#[from] RepositoryError),
62
63    #[error("internal error: {0}")]
64    Internal(String),
65}
66
67pub type ServiceResult<T> = Result<T, ServiceError>;
68
69// ─── DTO conversion traits ────────────────────────────────────────────────────
70
71/// Convert a Create DTO into a new entity instance.
72///
73/// Implemented once per entity by generated code, may be overridden in custom
74/// decorators via `UseCaseHooks`.
75pub trait FromCreateDto<DTO>: Sized {
76    fn from_create_dto(dto: DTO) -> ServiceResult<Self>;
77}
78
79/// Apply an Update DTO's fields to an existing entity (full update).
80pub trait ApplyUpdateDto<DTO> {
81    fn apply_update(self, dto: DTO) -> ServiceResult<Self>
82    where
83        Self: Sized;
84}
85
86// ─── ServiceLifecycle hooks ──────────────────────────────────────────────────
87
88/// Optional lifecycle callbacks injected into `GenericCrudService`.
89///
90/// Override individual methods — all default to no-op.
91#[async_trait]
92pub trait ServiceLifecycle<E: PersistentEntity>: Send + Sync {
93    async fn before_create(&self, entity: &mut E) -> ServiceResult<()> {
94        let _ = entity;
95        Ok(())
96    }
97
98    async fn after_create(&self, entity: &E) -> ServiceResult<()> {
99        let _ = entity;
100        Ok(())
101    }
102
103    async fn before_update(&self, entity: &mut E) -> ServiceResult<()> {
104        let _ = entity;
105        Ok(())
106    }
107
108    async fn after_update(&self, entity: &E) -> ServiceResult<()> {
109        let _ = entity;
110        Ok(())
111    }
112
113    async fn before_delete(&self, entity: &E) -> ServiceResult<()> {
114        let _ = entity;
115        Ok(())
116    }
117
118    async fn after_delete(&self, id: &str) -> ServiceResult<()> {
119        let _ = id;
120        Ok(())
121    }
122
123    /// Called before a soft-deleted entity is restored, singly or in a batch.
124    async fn before_restore(&self, entity: &E) -> ServiceResult<()> {
125        let _ = entity;
126        Ok(())
127    }
128
129    /// Called before a soft-deleted entity is removed for good, singly or in
130    /// a batch.
131    async fn before_hard_delete(&self, entity: &E) -> ServiceResult<()> {
132        let _ = entity;
133        Ok(())
134    }
135
136    /// Called once before the whole trash is restored. The rows are not
137    /// loaded for this; refuse here to keep the bulk path closed.
138    async fn before_restore_all(&self) -> ServiceResult<()> {
139        Ok(())
140    }
141
142    /// Called once before the whole trash is removed for good. The rows are
143    /// not loaded for this; refuse here to keep the bulk path closed.
144    async fn before_empty_trash(&self) -> ServiceResult<()> {
145        Ok(())
146    }
147}
148
149/// No-op lifecycle — the default used by generated type aliases.
150pub struct NoOpLifecycle<E> {
151    _phantom: PhantomData<E>,
152}
153
154impl<E> NoOpLifecycle<E> {
155    pub fn new() -> Self {
156        Self {
157            _phantom: PhantomData,
158        }
159    }
160}
161
162impl<E> Default for NoOpLifecycle<E> {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[async_trait]
169impl<E: PersistentEntity> ServiceLifecycle<E> for NoOpLifecycle<E> {}
170
171// ─── GenericCrudService ──────────────────────────────────────────────────────
172
173/// A fully generic CRUD service.
174///
175/// Type parameters:
176/// - `E`  — entity type (must implement `PersistentEntity`)
177/// - `C`  — create DTO (entity implements `FromCreateDto<C>`)
178/// - `U`  — update DTO (entity implements `ApplyUpdateDto<U>`)
179/// - `R`  — repository (implements `CrudRepository<E>`)
180///
181/// The service always holds an `Arc<dyn CrudEventPublisher<E>>`.  When no
182/// publisher is needed, `NoOpCrudEventPublisher` is used (zero overhead).
183/// Event-publish errors are fire-and-forget — they never fail the operation.
184pub struct GenericCrudService<E, C, U, R>
185where
186    E: PersistentEntity + Clone,
187    R: CrudRepository<E>,
188{
189    repository: Arc<R>,
190    lifecycle: Arc<dyn ServiceLifecycle<E>>,
191    event_publisher: Arc<dyn CrudEventPublisher<E>>,
192    guard: Arc<dyn crate::write_guard::WriteGuard<E>>,
193    _phantom: PhantomData<(C, U)>,
194}
195
196impl<E, C, U, R> GenericCrudService<E, C, U, R>
197where
198    E: PersistentEntity + Clone + FromCreateDto<C> + ApplyUpdateDto<U>,
199    C: Send + Sync + 'static,
200    U: Send + Sync + 'static,
201    R: CrudRepository<E>,
202{
203    /// Full constructor — supply all three dependencies.
204    pub fn new(
205        repository: Arc<R>,
206        lifecycle: Arc<dyn ServiceLifecycle<E>>,
207        event_publisher: Arc<dyn CrudEventPublisher<E>>,
208    ) -> Self {
209        Self {
210            repository,
211            lifecycle,
212            event_publisher,
213            guard: Arc::new(crate::write_guard::AllowAll),
214            _phantom: PhantomData,
215        }
216    }
217
218    /// Check every write against `guard`: its refusals refuse the write, its
219    /// shadowed violations are logged and the write proceeds.
220    pub fn with_guard(mut self, guard: Arc<dyn crate::write_guard::WriteGuard<E>>) -> Self {
221        self.guard = guard;
222        self
223    }
224
225    /// Ask the guard about one write.
226    async fn consult(
227        &self,
228        kind: crate::write_guard::WriteKind,
229        before: Option<&E>,
230        after: Option<&E>,
231    ) -> ServiceResult<()> {
232        self.consult_as(kind, before, after, true).await
233    }
234
235    /// [`consult`](Self::consult) for a dry run: the same refusals, without
236    /// logging what a shadow rule would refuse — the real write logs that, and a
237    /// preview before it would count every shadow violation twice.
238    async fn consult_quietly(
239        &self,
240        kind: crate::write_guard::WriteKind,
241        before: Option<&E>,
242        after: Option<&E>,
243    ) -> ServiceResult<()> {
244        self.consult_as(kind, before, after, false).await
245    }
246
247    async fn consult_as(
248        &self,
249        kind: crate::write_guard::WriteKind,
250        before: Option<&E>,
251        after: Option<&E>,
252        log_shadow: bool,
253    ) -> ServiceResult<()> {
254        let ctx = crate::write_guard::WriteCtx::new(kind, before, after);
255        let outcome = self.guard.check(&ctx).await;
256        for v in outcome.shadow.iter().filter(|_| log_shadow) {
257            tracing::warn!(
258                target: "backbone::write_guard",
259                entity = std::any::type_name::<E>(),
260                kind = ?kind,
261                code = %v.code,
262                path = %v.path,
263                "a rule running in shadow would refuse this write: {}",
264                v.message
265            );
266        }
267        if outcome.refuse.is_empty() {
268            Ok(())
269        } else {
270            Err(ServiceError::Violations(outcome.refuse))
271        }
272    }
273
274    /// Refuse a write that points one of the entity's references
275    /// ([`PersistentEntity::references`]) at a row the caller cannot see.
276    ///
277    /// Each reference the write sets — every one on a create, those whose value changes on
278    /// an update — is looked up through the repository on the request's own scope
279    /// ([`CrudRepository::reference_visible`]). A row that is missing or hidden by row-level
280    /// security answers `reference_missing` on the field, worded as the database's own
281    /// missing-reference answer is, so the refusal never tells that the row exists
282    /// elsewhere. A null reference and a repository that cannot tell are let through to the
283    /// database's key.
284    async fn check_references(&self, before: Option<&E>, after: &E) -> ServiceResult<()> {
285        let references = E::references();
286        if references.is_empty() {
287            return Ok(());
288        }
289        let after_obj = as_object(after)?;
290        let before_obj = before.map(as_object).transpose()?;
291        let mut refused = Vec::new();
292        for reference in references {
293            let Some(value) = after_obj.get(reference.field).filter(|v| !v.is_null()) else {
294                continue;
295            };
296            if before_obj.as_ref().is_some_and(|b| b.get(reference.field) == Some(value)) {
297                continue;
298            }
299            let visible = self
300                .repository
301                .reference_visible(reference.table, reference.column, value)
302                .await
303                .map_err(ServiceError::Repository)?;
304            if visible == Some(false) {
305                refused.push(crate::violation::Violation::new(
306                    reference.field,
307                    "reference_missing",
308                    format!("{} refers to a record that does not exist", reference.field),
309                ));
310            }
311        }
312        if refused.is_empty() {
313            Ok(())
314        } else {
315            Err(ServiceError::Violations(refused))
316        }
317    }
318
319    /// Create with a custom lifecycle and the no-op event publisher.
320    pub fn with_lifecycle(repository: Arc<R>, lifecycle: Arc<dyn ServiceLifecycle<E>>) -> Self {
321        Self::new(repository, lifecycle, NoOpCrudEventPublisher::arc())
322    }
323
324    /// Create with the no-op lifecycle and no-op event publisher (generated default).
325    pub fn with_repository(repository: Arc<R>) -> Self {
326        Self::new(
327            repository,
328            Arc::new(NoOpLifecycle::new()),
329            NoOpCrudEventPublisher::arc(),
330        )
331    }
332
333    /// Replace the event publisher on an existing service (builder pattern).
334    pub fn with_event_publisher(mut self, publisher: Arc<dyn CrudEventPublisher<E>>) -> Self {
335        self.event_publisher = publisher;
336        self
337    }
338
339    /// Access the underlying repository for custom query extensions.
340    pub fn repository(&self) -> &R {
341        &self.repository
342    }
343
344    // ─── Individual operations ────────────────────────────────────────────
345
346    pub async fn list(
347        &self,
348        page: u32,
349        limit: u32,
350        filters: HashMap<String, String>,
351    ) -> ServiceResult<(Vec<E>, u64)> {
352        self.repository
353            .list_filtered(page, limit, filters)
354            .await
355            .map_err(ServiceError::Repository)
356    }
357
358    /// `list`, carrying the pagination info (cursor positions included) so
359    /// the HTTP layer can surface keyset paging on every generic route.
360    pub async fn list_with_info(
361        &self,
362        page: u32,
363        limit: u32,
364        filters: HashMap<String, String>,
365    ) -> ServiceResult<(Vec<E>, backbone_orm::repository::PaginationInfo)> {
366        self.repository
367            .list_filtered_with_info(page, limit, filters)
368            .await
369            .map_err(ServiceError::Repository)
370    }
371
372    /// Group and reduce the rows `list` would return, under the same filters.
373    pub async fn aggregate(
374        &self,
375        spec: &backbone_orm::repository::AggregateSpec,
376        filters: HashMap<String, String>,
377    ) -> ServiceResult<backbone_orm::repository::AggregateResult> {
378        self.repository
379            .aggregate_filtered(spec, filters)
380            .await
381            .map_err(ServiceError::Repository)
382    }
383
384    pub async fn create(&self, dto: C) -> ServiceResult<E> {
385        let mut entity = E::from_create_dto(dto)?;
386        self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity)).await?;
387        self.lifecycle.before_create(&mut entity).await?;
388        self.check_references(None, &entity).await?;
389        let saved = self
390            .repository
391            .create(entity)
392            .await
393            .map_err(ServiceError::Repository)?;
394        self.lifecycle.after_create(&saved).await?;
395
396        let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
397        let _ = self
398            .event_publisher
399            .publish(CrudEvent::Created { entity: saved.clone(), metadata: meta })
400            .await;
401
402        Ok(saved)
403    }
404
405    pub async fn get_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
406        self.repository
407            .find_by_id(id)
408            .await
409            .map_err(ServiceError::Repository)
410    }
411
412    pub async fn update(&self, id: &str, dto: U) -> ServiceResult<Option<E>> {
413        let existing = self
414            .repository
415            .find_by_id(id)
416            .await
417            .map_err(ServiceError::Repository)?;
418        let Some(before) = existing else {
419            return Ok(None);
420        };
421        let mut updated = before.clone().apply_update(dto)?;
422        refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
423        self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
424        self.lifecycle.before_update(&mut updated).await?;
425        self.check_references(Some(&before), &updated).await?;
426        // The same changed-fields, live-row-only save as `partial_update`.
427        let Some(saved) = self
428            .repository
429            .update_changed(&before, updated)
430            .await
431            .map_err(ServiceError::Repository)?
432        else {
433            return Ok(None);
434        };
435        self.lifecycle.after_update(&saved).await?;
436
437        let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
438        let _ = self
439            .event_publisher
440            .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
441            .await;
442
443        Ok(Some(saved))
444    }
445
446    pub async fn soft_delete(&self, id: &str) -> ServiceResult<bool> {
447        let existing = self
448            .repository
449            .find_by_id(id)
450            .await
451            .map_err(ServiceError::Repository)?;
452        let Some(entity) = existing else {
453            return Ok(false);
454        };
455        self.consult(crate::write_guard::WriteKind::Delete, Some(&entity), None).await?;
456        self.lifecycle.before_delete(&entity).await?;
457        let deleted = self
458            .repository
459            .soft_delete(id)
460            .await
461            .map_err(ServiceError::Repository)?;
462        self.lifecycle.after_delete(id).await?;
463
464        if deleted {
465            let meta = EventMetadata::new(id, std::any::type_name::<E>());
466            let _ = self
467                .event_publisher
468                .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
469                .await;
470        }
471
472        Ok(deleted)
473    }
474
475    pub async fn restore(&self, id: &str) -> ServiceResult<Option<E>> {
476        let Some(trashed) = self.get_deleted_by_id(id).await? else {
477            return Ok(None);
478        };
479        self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
480        self.lifecycle.before_restore(&trashed).await?;
481        let restored = self
482            .repository
483            .restore(id)
484            .await
485            .map_err(ServiceError::Repository)?;
486
487        if let Some(ref entity) = restored {
488            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
489            let _ = self
490                .event_publisher
491                .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
492                .await;
493        }
494
495        Ok(restored)
496    }
497
498    pub async fn hard_delete(&self, id: &str) -> ServiceResult<bool> {
499        let Some(entity) = self.get_deleted_by_id(id).await? else {
500            return Ok(false);
501        };
502        self.consult(crate::write_guard::WriteKind::HardDelete, Some(&entity), None).await?;
503        self.lifecycle.before_hard_delete(&entity).await?;
504        let deleted = self
505            .repository
506            .hard_delete(id)
507            .await
508            .map_err(ServiceError::Repository)?;
509
510        if deleted {
511            let meta = EventMetadata::new(id, std::any::type_name::<E>());
512            let _ = self
513                .event_publisher
514                .publish(CrudEvent::HardDeleted {
515                    entity_id: id.to_string(),
516                    metadata: meta,
517                })
518                .await;
519        }
520
521        Ok(deleted)
522    }
523
524    pub async fn list_deleted(
525        &self,
526        page: u32,
527        limit: u32,
528    ) -> ServiceResult<(Vec<E>, u64)> {
529        self.repository
530            .list_deleted(page, limit)
531            .await
532            .map_err(ServiceError::Repository)
533    }
534
535    /// Permanently delete everything in the trash (atomic).
536    ///
537    /// The trashed rows are loaded first, at most [`MAX_BATCH_SIZE`] of them (a larger trash is
538    /// refused: empty it in batches by id), and the write guard and lifecycle are asked about
539    /// each one as a single permanent delete would. The delete then names exactly those ids and
540    /// rolls back unless every one is still in the trash, so a row trashed after the check is
541    /// never deleted unchecked.
542    pub async fn empty_trash(&self) -> ServiceResult<u64> {
543        self.lifecycle.before_empty_trash().await?;
544        let trashed = self.whole_trash("emptied").await?;
545        if trashed.is_empty() {
546            return Ok(0);
547        }
548        for entity in &trashed {
549            self.consult(crate::write_guard::WriteKind::HardDelete, Some(entity), None).await?;
550            self.lifecycle.before_hard_delete(entity).await?;
551        }
552        let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
553        let affected = self
554            .repository
555            .bulk_hard_delete(&ids)
556            .await
557            .map_err(ServiceError::Repository)?;
558        for id in &ids {
559            let meta = EventMetadata::new(id, std::any::type_name::<E>());
560            let _ = self
561                .event_publisher
562                .publish(CrudEvent::HardDeleted {
563                    entity_id: id.to_string(),
564                    metadata: meta,
565                })
566                .await;
567        }
568        Ok(affected)
569    }
570
571    /// Every row in the trash, refused when there are more than one batch may hold.
572    ///
573    /// Read page by page: a repository caps the rows one page may hold
574    /// ([`backbone_orm::repository::PaginationParams::MAX_PER_PAGE`]), so a single page
575    /// of [`MAX_BATCH_SIZE`] would quietly hold only the first hundred.
576    async fn whole_trash(&self, verb: &str) -> ServiceResult<Vec<E>> {
577        let too_many = |total: u64| {
578            ServiceError::Validation(format!(
579                "The trash holds {total} items, more than the {MAX_BATCH_SIZE} that can be {verb} \
580                 at once. Select the items to act on instead."
581            ))
582        };
583        let per_page = backbone_orm::repository::PaginationParams::MAX_PER_PAGE;
584        let mut rows: Vec<E> = Vec::new();
585        let mut page = 1u32;
586        loop {
587            let (batch, total) = self
588                .repository
589                .list_deleted(page, per_page)
590                .await
591                .map_err(ServiceError::Repository)?;
592            if total > MAX_BATCH_SIZE as u64 {
593                return Err(too_many(total));
594            }
595            let fetched = batch.len();
596            rows.extend(batch);
597            if rows.len() > MAX_BATCH_SIZE {
598                return Err(too_many(rows.len() as u64));
599            }
600            if fetched == 0 || rows.len() as u64 >= total || fetched < per_page as usize {
601                break;
602            }
603            page += 1;
604        }
605        Ok(rows)
606    }
607
608    pub async fn count(&self) -> ServiceResult<u64> {
609        self.repository
610            .count()
611            .await
612            .map_err(ServiceError::Repository)
613    }
614
615    pub async fn count_active(&self) -> ServiceResult<u64> {
616        self.repository
617            .count()
618            .await
619            .map_err(ServiceError::Repository)
620    }
621
622    /// Alias for `get_by_id` — used by generated handlers.
623    pub async fn find_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
624        self.get_by_id(id).await
625    }
626
627    /// Alias for `hard_delete` — used by generated handlers.
628    pub async fn permanent_delete(&self, id: &str) -> ServiceResult<bool> {
629        self.hard_delete(id).await
630    }
631
632    /// Count soft-deleted entities.
633    pub async fn count_deleted(&self) -> ServiceResult<u64> {
634        self.repository
635            .count_deleted()
636            .await
637            .map_err(ServiceError::Repository)
638    }
639
640    /// Retrieve a soft-deleted entity by ID (includes deleted records).
641    pub async fn get_deleted_by_id(&self, id: &str) -> ServiceResult<Option<E>> {
642        self.repository
643            .find_by_id_including_deleted(id)
644            .await
645            .map_err(ServiceError::Repository)
646    }
647
648    /// List deleted entities with the same filters and search the active list takes.
649    pub async fn list_deleted_filtered(
650        &self,
651        page: u32,
652        limit: u32,
653        filters: HashMap<String, String>,
654    ) -> ServiceResult<(Vec<E>, u64)> {
655        self.repository
656            .list_deleted_filtered(page, limit, filters)
657            .await
658            .map_err(ServiceError::Repository)
659    }
660
661    /// Upsert is not available on the generic service, and refuses rather than guessing.
662    ///
663    /// An upsert must match the record it would update, by id or by a natural key. A create
664    /// DTO carries no id and the framework knows no natural key for an entity, so the only thing
665    /// this could do is create — which is what it used to do, silently duplicating the record on
666    /// every retry. The generic HTTP route answers 501 for the same reason. An entity with a real
667    /// upsert implements it in its own service.
668    pub async fn upsert(&self, dto: C) -> ServiceResult<E> {
669        let _ = dto;
670        Err(ServiceError::Validation(UPSERT_UNSUPPORTED.to_string()))
671    }
672
673    /// Apply a partial update from a JSON map of field values.
674    ///
675    /// Fields not present in the map are left unchanged. A key the entity does
676    /// not have, or a new value for a protected field, refuses the whole write.
677    pub async fn partial_update(
678        &self,
679        id: &str,
680        fields: HashMap<String, serde_json::Value>,
681    ) -> ServiceResult<Option<E>>
682    where
683        E: serde::Serialize + serde::de::DeserializeOwned,
684    {
685        let existing = self
686            .repository
687            .find_by_id(id)
688            .await
689            .map_err(ServiceError::Repository)?;
690        let Some(before) = existing else {
691            return Ok(None);
692        };
693        let mut patched = merge_patch(&before, fields)?;
694        self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
695        self.lifecycle.before_update(&mut patched).await?;
696        self.check_references(Some(&before), &patched).await?;
697        // Only the fields this write changed are saved, and only while the row is still live:
698        // see `CrudRepository::update_changed`. A row deleted since the read answers not found.
699        let Some(saved) = self
700            .repository
701            .update_changed(&before, patched)
702            .await
703            .map_err(ServiceError::Repository)?
704        else {
705            return Ok(None);
706        };
707        self.lifecycle.after_update(&saved).await?;
708
709        let meta = EventMetadata::new(saved.entity_id(), std::any::type_name::<E>());
710        let _ = self
711            .event_publisher
712            .publish(CrudEvent::Updated { before, after: saved.clone(), metadata: meta })
713            .await;
714
715        Ok(Some(saved))
716    }
717
718    /// Create many entities, all or nothing.
719    ///
720    /// Each item goes through what a single create does — the DTO conversion, the write guard,
721    /// the `before_create` hook — before anything is written; then the repository inserts them
722    /// in one transaction. A failure anywhere fails the whole batch and names the item: a
723    /// violation's path starts with the item's index (`3/name`).
724    pub async fn bulk_create(&self, dtos: Vec<C>) -> ServiceResult<Vec<E>>
725    where
726        E: FromCreateDto<C>,
727    {
728        check_batch_size(dtos.len())?;
729        let mut prepared = Vec::with_capacity(dtos.len());
730        for (index, dto) in dtos.into_iter().enumerate() {
731            let at = |e: ServiceError| at_batch_index(e, index);
732            let mut entity = E::from_create_dto(dto).map_err(at)?;
733            self.consult(crate::write_guard::WriteKind::Create, None, Some(&entity))
734                .await
735                .map_err(at)?;
736            self.lifecycle.before_create(&mut entity).await.map_err(at)?;
737            self.check_references(None, &entity).await.map_err(at)?;
738            prepared.push(entity);
739        }
740        let saved = self
741            .repository
742            .bulk_create(prepared)
743            .await
744            .map_err(ServiceError::Repository)?;
745        for entity in &saved {
746            self.lifecycle.after_create(entity).await?;
747            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
748            let _ = self
749                .event_publisher
750                .publish(CrudEvent::Created { entity: entity.clone(), metadata: meta })
751                .await;
752        }
753        Ok(saved)
754    }
755
756    // ── Atomic batch operations ───────────────────────────────────────────────
757    //
758    // All-or-nothing: ids are validated up-front (so a missing id fails the whole
759    // request before any write), then the repository applies the change inside a
760    // single transaction. Lifecycle hooks and CRUD events fire per affected
761    // entity, mirroring the single-row methods. Events are best-effort.
762
763    /// Soft-delete many entities by id (atomic).
764    pub async fn bulk_soft_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
765        check_batch_size(ids.len())?;
766        let ids = dedup_ids(ids);
767        // Load the entities up-front: they are the payloads for the lifecycle
768        // `before_delete` hook and the `SoftDeleted` event below. The repository's
769        // atomic affected-row check (not this loop) is what guarantees the write
770        // is all-or-nothing.
771        let mut entities = Vec::with_capacity(ids.len());
772        for id in &ids {
773            match self
774                .repository
775                .find_by_id(id)
776                .await
777                .map_err(ServiceError::Repository)?
778            {
779                Some(e) => entities.push(e),
780                None => return Err(ServiceError::Validation(format!("id '{id}' not found"))),
781            }
782        }
783        for entity in &entities {
784            self.consult(crate::write_guard::WriteKind::Delete, Some(entity), None).await?;
785            self.lifecycle.before_delete(entity).await?;
786        }
787        let affected = self
788            .repository
789            .bulk_soft_delete(&ids)
790            .await
791            .map_err(ServiceError::Repository)?;
792        for (id, entity) in ids.iter().zip(entities.into_iter()) {
793            self.lifecycle.after_delete(id).await?;
794            let meta = EventMetadata::new(id, std::any::type_name::<E>());
795            let _ = self
796                .event_publisher
797                .publish(CrudEvent::SoftDeleted { entity, metadata: meta })
798                .await;
799        }
800        Ok(affected)
801    }
802
803    /// Restore many soft-deleted entities by id (atomic).
804    pub async fn bulk_restore(&self, ids: Vec<String>) -> ServiceResult<Vec<E>> {
805        check_batch_size(ids.len())?;
806        let ids = dedup_ids(ids);
807        // Ask the lifecycle about every row it can see; an id that is not in
808        // the trash is left to the repository's atomic check below.
809        for id in &ids {
810            if let Some(trashed) = self.get_deleted_by_id(id).await? {
811                self.consult(crate::write_guard::WriteKind::Restore, Some(&trashed), None).await?;
812                self.lifecycle.before_restore(&trashed).await?;
813            }
814        }
815        let restored = self
816            .repository
817            .bulk_restore(&ids)
818            .await
819            .map_err(ServiceError::Repository)?;
820        for entity in &restored {
821            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
822            let _ = self
823                .event_publisher
824                .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
825                .await;
826        }
827        Ok(restored)
828    }
829
830    /// Restore every soft-deleted entity (atomic). Returns the number restored.
831    /// Emits a `Restored` event per entity, mirroring [`bulk_restore`](Self::bulk_restore).
832    ///
833    /// Like [`empty_trash`](Self::empty_trash), the trashed rows are loaded first (at most
834    /// [`MAX_BATCH_SIZE`]), each is put to the write guard and lifecycle as a single restore
835    /// would be, and the restore names exactly those ids, all or nothing.
836    pub async fn restore_all(&self) -> ServiceResult<u64> {
837        self.lifecycle.before_restore_all().await?;
838        let trashed = self.whole_trash("restored").await?;
839        if trashed.is_empty() {
840            return Ok(0);
841        }
842        for entity in &trashed {
843            self.consult(crate::write_guard::WriteKind::Restore, Some(entity), None).await?;
844            self.lifecycle.before_restore(entity).await?;
845        }
846        let ids: Vec<String> = trashed.iter().map(|e| e.entity_id()).collect();
847        let restored = self
848            .repository
849            .bulk_restore(&ids)
850            .await
851            .map_err(ServiceError::Repository)?;
852        for entity in &restored {
853            let meta = EventMetadata::new(entity.entity_id(), std::any::type_name::<E>());
854            let _ = self
855                .event_publisher
856                .publish(CrudEvent::Restored { entity: entity.clone(), metadata: meta })
857                .await;
858        }
859        Ok(restored.len() as u64)
860    }
861
862    /// Permanently delete many soft-deleted entities by id (atomic).
863    pub async fn bulk_permanent_delete(&self, ids: Vec<String>) -> ServiceResult<u64> {
864        check_batch_size(ids.len())?;
865        let ids = dedup_ids(ids);
866        // The lifecycle is asked about every row it can see. Whether every id
867        // is in the trash stays with `bulk_hard_delete`, which deletes only
868        // trashed rows and rolls the whole batch back unless every id matched,
869        // so a row that moves between this loop and the write cannot slip
870        // through.
871        for id in &ids {
872            if let Some(trashed) = self.get_deleted_by_id(id).await? {
873                self.consult(crate::write_guard::WriteKind::HardDelete, Some(&trashed), None).await?;
874                self.lifecycle.before_hard_delete(&trashed).await?;
875            }
876        }
877        let affected = self
878            .repository
879            .bulk_hard_delete(&ids)
880            .await
881            .map_err(ServiceError::Repository)?;
882        for id in &ids {
883            let meta = EventMetadata::new(id, std::any::type_name::<E>());
884            let _ = self
885                .event_publisher
886                .publish(CrudEvent::HardDeleted {
887                    entity_id: id.to_string(),
888                    metadata: meta,
889                })
890                .await;
891        }
892        Ok(affected)
893    }
894
895    /// Full-update many entities (atomic). Each item is `(id, UpdateDto)`.
896    pub async fn bulk_update(&self, items: Vec<(String, U)>) -> ServiceResult<Vec<E>> {
897        check_batch_size(items.len())?;
898        if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
899            return Err(ServiceError::Validation(format!(
900                "duplicate id '{dup}' in bulk update"
901            )));
902        }
903        let mut befores = Vec::with_capacity(items.len());
904        let mut prepared = Vec::with_capacity(items.len());
905        for (index, (id, dto)) in items.into_iter().enumerate() {
906            let Some(before) = self
907                .repository
908                .find_by_id(&id)
909                .await
910                .map_err(ServiceError::Repository)?
911            else {
912                return Err(ServiceError::Validation(format!("id '{id}' not found")));
913            };
914            let mut updated = before.clone().apply_update(dto)?;
915            refuse_protected_changes::<E>(&as_object(&before)?, &as_object(&updated)?)?;
916            self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&updated)).await?;
917            self.lifecycle.before_update(&mut updated).await?;
918            self.check_references(Some(&before), &updated)
919                .await
920                .map_err(|e| at_batch_index(e, index))?;
921            prepared.push((before.clone(), updated));
922            befores.push(before);
923        }
924        // Only the fields each item changed are written, as a single PUT does, so a column a
925        // concurrent verb moved is not written back with the value read before it.
926        let saved = self
927            .repository
928            .bulk_update_changed(prepared)
929            .await
930            .map_err(ServiceError::Repository)?;
931        self.publish_bulk_updates(befores, &saved).await?;
932        Ok(saved)
933    }
934
935    /// Fire `after_update` and a `Updated` event for each `(before, after)` pair.
936    /// Shared by [`bulk_update`](Self::bulk_update) and
937    /// [`bulk_partial_update`](Self::bulk_partial_update); events are best-effort.
938    async fn publish_bulk_updates(&self, befores: Vec<E>, saved: &[E]) -> ServiceResult<()> {
939        for (before, after) in befores.into_iter().zip(saved.iter()) {
940            self.lifecycle.after_update(after).await?;
941            let meta = EventMetadata::new(after.entity_id(), std::any::type_name::<E>());
942            let _ = self
943                .event_publisher
944                .publish(CrudEvent::Updated {
945                    before,
946                    after: after.clone(),
947                    metadata: meta,
948                })
949                .await;
950        }
951        Ok(())
952    }
953
954    /// Partial-update many entities (atomic). Each item is `(id, field_map)`.
955    pub async fn bulk_partial_update(
956        &self,
957        items: Vec<(String, HashMap<String, serde_json::Value>)>,
958    ) -> ServiceResult<Vec<E>>
959    where
960        E: serde::Serialize + serde::de::DeserializeOwned,
961    {
962        check_batch_size(items.len())?;
963        if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
964            return Err(ServiceError::Validation(format!(
965                "duplicate id '{dup}' in bulk partial update"
966            )));
967        }
968        let mut befores = Vec::with_capacity(items.len());
969        let mut prepared = Vec::with_capacity(items.len());
970        for (index, (id, fields)) in items.into_iter().enumerate() {
971            let Some(before) = self
972                .repository
973                .find_by_id(&id)
974                .await
975                .map_err(ServiceError::Repository)?
976            else {
977                return Err(ServiceError::Validation(format!("id '{id}' not found")));
978            };
979            let mut patched = merge_patch(&before, fields)?;
980            self.consult(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched)).await?;
981            self.lifecycle.before_update(&mut patched).await?;
982            self.check_references(Some(&before), &patched)
983                .await
984                .map_err(|e| at_batch_index(e, index))?;
985            prepared.push((before.clone(), patched));
986            befores.push(before);
987        }
988        let saved = self
989            .repository
990            .bulk_update_changed(prepared)
991            .await
992            .map_err(ServiceError::Repository)?;
993        self.publish_bulk_updates(befores, &saved).await?;
994        Ok(saved)
995    }
996
997    /// What [`bulk_partial_update`](Self::bulk_partial_update) would do, with
998    /// nothing written. Each row goes through the same steps the write takes —
999    /// it is found, the patch is merged and checked against the protected fields,
1000    /// the write rules are consulted (quietly: a shadow rule logs on the real
1001    /// write, not here) and the update hook runs — and its outcome is
1002    /// kept instead of failing the batch, so one dialog can say which rows would
1003    /// change and which would be refused. Only a batch that is too large or names
1004    /// an id twice fails as a whole, as the write does.
1005    pub async fn preview_bulk_partial_update(
1006        &self,
1007        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1008    ) -> ServiceResult<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>
1009    where
1010        E: serde::Serialize + serde::de::DeserializeOwned,
1011    {
1012        check_batch_size(items.len())?;
1013        if let Some(dup) = first_duplicate_id(items.iter().map(|(id, _)| id.clone())) {
1014            return Err(ServiceError::Validation(format!(
1015                "duplicate id '{dup}' in bulk partial update"
1016            )));
1017        }
1018        let mut rows = Vec::with_capacity(items.len());
1019        for (id, fields) in items {
1020            let outcome = self.preview_one(&id, fields).await;
1021            rows.push(crate::http::BulkPreviewRow { id, outcome });
1022        }
1023        Ok(rows)
1024    }
1025
1026    async fn preview_one(
1027        &self,
1028        id: &str,
1029        fields: HashMap<String, serde_json::Value>,
1030    ) -> ServiceResult<(E, E)>
1031    where
1032        E: serde::Serialize + serde::de::DeserializeOwned,
1033    {
1034        let Some(before) = self
1035            .repository
1036            .find_by_id(id)
1037            .await
1038            .map_err(ServiceError::Repository)?
1039        else {
1040            return Err(ServiceError::Validation(format!("id '{id}' not found")));
1041        };
1042        let mut patched = merge_patch(&before, fields)?;
1043        self.consult_quietly(crate::write_guard::WriteKind::Update, Some(&before), Some(&patched))
1044            .await?;
1045        self.lifecycle.before_update(&mut patched).await?;
1046        self.check_references(Some(&before), &patched).await?;
1047        Ok((before, patched))
1048    }
1049}
1050
1051// ─── Generic write protection ────────────────────────────────────────────────
1052
1053/// Serialized fields no generic write may change on any entity: the row's
1054/// identity, and the audit block that records who created, changed and
1055/// deleted it. Entities add their own through
1056/// [`PersistentEntity::write_protected_fields`].
1057pub const ALWAYS_WRITE_PROTECTED: &[&str] = &["id", "metadata"];
1058
1059fn as_object(entity: &impl serde::Serialize) -> ServiceResult<serde_json::Map<String, serde_json::Value>> {
1060    match serde_json::to_value(entity).map_err(|e| ServiceError::Internal(e.to_string()))? {
1061        serde_json::Value::Object(map) => Ok(map),
1062        _ => Err(ServiceError::Internal("an entity must serialize to a JSON object".into())),
1063    }
1064}
1065
1066/// Refuse a generic write that gives a protected field a new value. Carrying
1067/// the stored value unchanged is allowed, so a form that sends the whole
1068/// record is not refused for fields it did not touch.
1069///
1070/// A state machine's verb writes through the same path, inside
1071/// [`as_transition`](crate::write_guard::as_transition), and its whole point is
1072/// to move the state field the entity protects from generic writes. Inside a
1073/// transition the entity's [`PersistentEntity::state_fields`] are therefore let
1074/// through — those and nothing else; the write guard still decides whether the
1075/// move itself is allowed.
1076fn refuse_protected_changes<E: PersistentEntity>(
1077    before: &serde_json::Map<String, serde_json::Value>,
1078    after: &serde_json::Map<String, serde_json::Value>,
1079) -> ServiceResult<()> {
1080    let in_transition = crate::write_guard::current_transition().is_some();
1081    let refused: Vec<crate::violation::Violation> = ALWAYS_WRITE_PROTECTED
1082        .iter()
1083        .chain(E::write_protected_fields())
1084        .filter(|key| !(in_transition && E::state_fields().contains(*key)))
1085        .filter(|key| before.get(**key) != after.get(**key))
1086        .map(|key| {
1087            crate::violation::Violation::new(
1088                *key,
1089                "field_not_writable",
1090                format!(
1091                    "field_not_writable: `{key}` cannot be changed by a generic write; \
1092                     it changes only through the operation that owns it"
1093                ),
1094            )
1095        })
1096        .collect();
1097    if refused.is_empty() {
1098        Ok(())
1099    } else {
1100        Err(ServiceError::Violations(refused))
1101    }
1102}
1103
1104/// Merge a partial update onto an entity.
1105///
1106/// The merge goes through the entity's JSON form, so a key the entity has no
1107/// field for would otherwise be dropped without a word and the edit lost. It
1108/// is refused instead: a key given a value that does not survive the round
1109/// trip is not a field of this record. A key set to `null` is exempt, since
1110/// an empty optional field may be left out of the JSON form entirely.
1111fn merge_patch<E: PersistentEntity>(
1112    before: &E,
1113    fields: HashMap<String, serde_json::Value>,
1114) -> ServiceResult<E> {
1115    let before_obj = as_object(before)?;
1116    let mut merged = before_obj.clone();
1117    let mut given = Vec::new();
1118    for (key, value) in fields {
1119        if !value.is_null() {
1120            given.push(key.clone());
1121        }
1122        merged.insert(key, value);
1123    }
1124    let patched: E = serde_path_to_error::deserialize(serde_json::Value::Object(merged))
1125        .map_err(|e| patch_type_refusal(&e))?;
1126    let after_obj = as_object(&patched)?;
1127
1128    let mut unknown: Vec<String> = given.into_iter().filter(|k| !after_obj.contains_key(k)).collect();
1129    if !unknown.is_empty() {
1130        unknown.sort();
1131        return Err(ServiceError::Violations(
1132            unknown
1133                .into_iter()
1134                .map(|k| {
1135                    let message = format!("unknown_field: `{k}` is not a field of this record");
1136                    crate::violation::Violation::new(k, "unknown_field", message)
1137                })
1138                .collect(),
1139        ));
1140    }
1141    refuse_protected_changes::<E>(&before_obj, &after_obj)?;
1142    Ok(patched)
1143}
1144
1145/// The refusal for a patch value the entity cannot hold: `required` on a field set to null
1146/// that may not be empty, `invalid_value` on a field given a value of the wrong kind — the
1147/// codes a create body's decoding answers with for the same mistakes — on the field's path.
1148fn patch_type_refusal(err: &serde_path_to_error::Error<serde_json::Error>) -> ServiceError {
1149    let path = err
1150        .path()
1151        .iter()
1152        .filter_map(|segment| match segment {
1153            serde_path_to_error::Segment::Seq { index } => Some(index.to_string()),
1154            serde_path_to_error::Segment::Map { key } => Some(key.clone()),
1155            serde_path_to_error::Segment::Enum { variant } => Some(variant.clone()),
1156            serde_path_to_error::Segment::Unknown => None,
1157        })
1158        .collect::<Vec<_>>()
1159        .join("/");
1160    let message = err.inner().to_string();
1161    let message = match message.rfind(" at line ") {
1162        Some(at) => message[..at].to_string(),
1163        None => message,
1164    };
1165    let violation = if let Some(field) = message
1166        .strip_prefix("missing field `")
1167        .and_then(|rest| rest.split('`').next())
1168    {
1169        let field_path = if path.is_empty() { field.to_string() } else { format!("{path}/{field}") };
1170        crate::violation::Violation::new(field_path.clone(), "required", format!("{field_path} is required"))
1171    } else if message.starts_with("invalid type: null") {
1172        let subject = if path.is_empty() { "A value".to_string() } else { path.clone() };
1173        crate::violation::Violation::new(path, "required", format!("{subject} is required"))
1174    } else {
1175        let text = if path.is_empty() { message } else { format!("{path}: {message}") };
1176        crate::violation::Violation::new(path, "invalid_value", text)
1177    };
1178    ServiceError::Violations(vec![violation])
1179}
1180
1181/// Maximum number of ids / items a single batch operation may contain. Enforced
1182/// here at the service layer (not only in the HTTP handlers) so every caller —
1183/// gRPC, background jobs, internal code — gets the same bound.
1184pub const MAX_BATCH_SIZE: usize = 1000;
1185
1186/// Reject an over-sized batch before any work is done.
1187fn check_batch_size(count: usize) -> Result<(), ServiceError> {
1188    if count > MAX_BATCH_SIZE {
1189        return Err(ServiceError::Validation(format!(
1190            "Batch too large: {count} items exceeds the maximum of {MAX_BATCH_SIZE}."
1191        )));
1192    }
1193    Ok(())
1194}
1195
1196/// The refusal [`GenericCrudService::upsert`] answers with.
1197pub const UPSERT_UNSUPPORTED: &str =
1198    "upsert is not available for this resource: a create body names no record to match. \
1199     Create the record, or update it by id.";
1200
1201/// Place a service error on item `index` of a batch (see
1202/// [`RepositoryError::at_batch_index`]).
1203fn at_batch_index(err: ServiceError, index: usize) -> ServiceError {
1204    let item = |message: String| format!("item {index}: {message}");
1205    match err {
1206        ServiceError::Violations(violations) => ServiceError::Violations(
1207            violations
1208                .into_iter()
1209                .map(|mut v| {
1210                    v.path = crate::violation::batch_item_path(index, &v.path);
1211                    v
1212                })
1213                .collect(),
1214        ),
1215        ServiceError::Repository(e) => ServiceError::Repository(e.at_batch_index(index)),
1216        ServiceError::Validation(m) => ServiceError::Validation(item(m)),
1217        ServiceError::AlreadyExists(m) => ServiceError::AlreadyExists(item(m)),
1218        ServiceError::Internal(m) => ServiceError::Internal(item(m)),
1219        ServiceError::NotFound => ServiceError::Validation(item("not found".to_string())),
1220    }
1221}
1222
1223/// Drop duplicate ids while preserving first-seen order. Id-list batch ops issue
1224/// a SQL `IN (...)` whose distinct-row count would never match a list that
1225/// repeats an id, so a stray duplicate would otherwise fail the whole batch.
1226fn dedup_ids(ids: Vec<String>) -> Vec<String> {
1227    let mut seen = std::collections::HashSet::new();
1228    ids.into_iter().filter(|id| seen.insert(id.clone())).collect()
1229}
1230
1231/// Return the first id that appears more than once, if any. Used to reject
1232/// ambiguous bulk updates (the same id mapped to two different payloads).
1233fn first_duplicate_id(ids: impl IntoIterator<Item = String>) -> Option<String> {
1234    let mut seen = std::collections::HashSet::new();
1235    for id in ids {
1236        if !seen.insert(id.clone()) {
1237            return Some(id);
1238        }
1239    }
1240    None
1241}
1242
1243// ─── Blanket CrudService impl ────────────────────────────────────────────────
1244
1245/// Blanket impl so that any `GenericCrudService<E, C, U, R>` automatically
1246/// satisfies `CrudService<E, C, U>` without generated adapter structs.
1247///
1248/// Generated code emits only a type alias:
1249///   `pub type UserService = GenericCrudService<User, CreateUserDto, UpdateUserDto, UserRepository>;`
1250///
1251/// That alias now directly implements `CrudService` and can be passed to
1252/// `BackboneCrudHandler` without any additional boilerplate.
1253#[async_trait::async_trait]
1254impl<E, C, U, R> crate::http::CrudService<E, C, U> for GenericCrudService<E, C, U, R>
1255where
1256    E: PersistentEntity
1257        + Clone
1258        + serde::Serialize
1259        + serde::de::DeserializeOwned
1260        + FromCreateDto<C>
1261        + ApplyUpdateDto<U>
1262        + Send
1263        + Sync
1264        + 'static,
1265    C: Send + Sync + 'static,
1266    U: Send + Sync + 'static,
1267    R: CrudRepository<E> + Send + Sync + 'static,
1268{
1269    type Error = ServiceError;
1270
1271    fn violations_of(err: &ServiceError) -> Option<Vec<crate::violation::Violation>> {
1272        match err {
1273            ServiceError::Violations(v) | ServiceError::Repository(RepositoryError::Violations(v)) => Some(v.clone()),
1274            _ => None,
1275        }
1276    }
1277
1278    fn status_of(err: &ServiceError) -> Option<axum::http::StatusCode> {
1279        Some(crate::http::service_error_status(err))
1280    }
1281
1282    fn supports_upsert() -> bool {
1283        false
1284    }
1285
1286    fn entity_name() -> &'static str {
1287        // The type's own name, never its module path: it reaches log lines and must not
1288        // describe the crate layout to anyone reading a response built from it.
1289        let full = std::any::type_name::<E>();
1290        full.rsplit("::").next().unwrap_or(full)
1291    }
1292
1293    async fn fetch_related_json(&self, table: &str, ids: &[String]) -> Vec<serde_json::Value> {
1294        self.repository().fetch_related_json(table, ids).await
1295    }
1296
1297    async fn list(
1298        &self,
1299        page: u32,
1300        limit: u32,
1301        filters: HashMap<String, String>,
1302    ) -> Result<(Vec<E>, u64), ServiceError> {
1303        self.list(page, limit, filters).await
1304    }
1305
1306    async fn list_with_info(
1307        &self,
1308        page: u32,
1309        limit: u32,
1310        filters: HashMap<String, String>,
1311    ) -> Result<(Vec<E>, backbone_orm::repository::PaginationInfo), ServiceError> {
1312        self.list_with_info(page, limit, filters).await
1313    }
1314
1315    async fn aggregate(
1316        &self,
1317        spec: &backbone_orm::repository::AggregateSpec,
1318        filters: HashMap<String, String>,
1319    ) -> Result<backbone_orm::repository::AggregateResult, ServiceError> {
1320        self.aggregate(spec, filters).await
1321    }
1322
1323    fn table_name(&self) -> Option<&str> {
1324        self.repository.table_name()
1325    }
1326
1327    async fn create(&self, dto: C) -> Result<E, ServiceError> {
1328        self.create(dto).await
1329    }
1330
1331    async fn get_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1332        self.get_by_id(id).await
1333    }
1334
1335    async fn update(&self, id: &str, dto: U) -> Result<Option<E>, ServiceError> {
1336        self.update(id, dto).await
1337    }
1338
1339    async fn partial_update(
1340        &self,
1341        id: &str,
1342        fields: HashMap<String, serde_json::Value>,
1343    ) -> Result<Option<E>, ServiceError> {
1344        self.partial_update(id, fields).await
1345    }
1346
1347    async fn soft_delete(&self, id: &str) -> Result<bool, ServiceError> {
1348        self.soft_delete(id).await
1349    }
1350
1351    async fn bulk_create(&self, items: Vec<C>) -> Result<Vec<E>, ServiceError> {
1352        self.bulk_create(items).await
1353    }
1354
1355    async fn upsert(&self, dto: C) -> Result<E, ServiceError> {
1356        self.upsert(dto).await
1357    }
1358
1359    async fn list_deleted(&self, page: u32, limit: u32) -> Result<(Vec<E>, u64), ServiceError> {
1360        self.list_deleted(page, limit).await
1361    }
1362
1363    async fn restore(&self, id: &str) -> Result<Option<E>, ServiceError> {
1364        self.restore(id).await
1365    }
1366
1367    async fn empty_trash(&self) -> Result<u64, ServiceError> {
1368        self.empty_trash().await
1369    }
1370
1371    async fn get_deleted_by_id(&self, id: &str) -> Result<Option<E>, ServiceError> {
1372        self.get_deleted_by_id(id).await
1373    }
1374
1375    async fn permanent_delete(&self, id: &str) -> Result<bool, ServiceError> {
1376        self.permanent_delete(id).await
1377    }
1378
1379    async fn list_deleted_filtered(
1380        &self,
1381        page: u32,
1382        limit: u32,
1383        filters: HashMap<String, String>,
1384    ) -> Result<(Vec<E>, u64), ServiceError> {
1385        self.list_deleted_filtered(page, limit, filters).await
1386    }
1387
1388    async fn count_active(&self) -> Result<u64, ServiceError> {
1389        self.count_active().await
1390    }
1391
1392    async fn count_deleted(&self) -> Result<u64, ServiceError> {
1393        self.count_deleted().await
1394    }
1395
1396    async fn bulk_soft_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1397        self.bulk_soft_delete(ids).await
1398    }
1399
1400    async fn bulk_restore(&self, ids: Vec<String>) -> Result<Vec<E>, ServiceError> {
1401        self.bulk_restore(ids).await
1402    }
1403
1404    async fn bulk_permanent_delete(&self, ids: Vec<String>) -> Result<u64, ServiceError> {
1405        self.bulk_permanent_delete(ids).await
1406    }
1407
1408    async fn restore_all(&self) -> Result<u64, ServiceError> {
1409        self.restore_all().await
1410    }
1411
1412    async fn bulk_update(&self, items: Vec<(String, U)>) -> Result<Vec<E>, ServiceError> {
1413        self.bulk_update(items).await
1414    }
1415
1416    async fn bulk_partial_update(
1417        &self,
1418        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1419    ) -> Result<Vec<E>, ServiceError> {
1420        self.bulk_partial_update(items).await
1421    }
1422
1423    async fn preview_bulk_partial_update(
1424        &self,
1425        items: Vec<(String, HashMap<String, serde_json::Value>)>,
1426    ) -> Result<Option<Vec<crate::http::BulkPreviewRow<E, ServiceError>>>, ServiceError> {
1427        self.preview_bulk_partial_update(items).await.map(Some)
1428    }
1429}
1430
1431// Make the service cloneable (needed when Arc-ing it and sharing across handlers).
1432impl<E, C, U, R> Clone for GenericCrudService<E, C, U, R>
1433where
1434    E: PersistentEntity + Clone,
1435    R: CrudRepository<E>,
1436{
1437    fn clone(&self) -> Self {
1438        Self {
1439            repository: self.repository.clone(),
1440            lifecycle: self.lifecycle.clone(),
1441            event_publisher: self.event_publisher.clone(),
1442            guard: self.guard.clone(),
1443            _phantom: PhantomData,
1444        }
1445    }
1446}
1447
1448#[cfg(test)]
1449mod tests {
1450    use super::*;
1451    use chrono::{DateTime, Utc};
1452    use serde::{Deserialize, Serialize};
1453
1454    // ── Minimal in-memory repository for testing ──────────────────────────
1455
1456    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1457    struct Widget {
1458        id: String,
1459        name: String,
1460        #[serde(skip_serializing_if = "Option::is_none")]
1461        note: Option<String>,
1462        secret_hash: String,
1463        /// A state-machine field: protected from generic writes, moved by verbs.
1464        #[serde(default)]
1465        state: String,
1466        /// A reference to an owner row, checked for visibility before a write sets it.
1467        #[serde(default, skip_serializing_if = "Option::is_none")]
1468        owner_id: Option<String>,
1469        #[serde(skip_serializing_if = "Option::is_none")]
1470        deleted_at: Option<DateTime<Utc>>,
1471        created_at: DateTime<Utc>,
1472        updated_at: DateTime<Utc>,
1473    }
1474
1475    impl crate::persistence::traits::PersistentEntity for Widget {
1476        fn write_protected_fields() -> &'static [&'static str] {
1477            &["secret_hash", "state"]
1478        }
1479        fn state_fields() -> &'static [&'static str] {
1480            &["state"]
1481        }
1482        fn references() -> &'static [crate::persistence::traits::Reference] {
1483            // The shape the schema generator emits: a `const`, since a `const fn` call is not
1484            // promoted to a `'static` borrow on its own.
1485            const REFERENCES: &[crate::persistence::traits::Reference] =
1486                &[crate::persistence::traits::Reference::new("owner_id", "owners", "id")];
1487            REFERENCES
1488        }
1489        fn entity_id(&self) -> String {
1490            self.id.clone()
1491        }
1492        fn set_entity_id(&mut self, id: String) {
1493            self.id = id;
1494        }
1495        fn created_at(&self) -> Option<DateTime<Utc>> {
1496            Some(self.created_at)
1497        }
1498        fn set_created_at(&mut self, ts: DateTime<Utc>) {
1499            self.created_at = ts;
1500        }
1501        fn updated_at(&self) -> Option<DateTime<Utc>> {
1502            Some(self.updated_at)
1503        }
1504        fn set_updated_at(&mut self, ts: DateTime<Utc>) {
1505            self.updated_at = ts;
1506        }
1507        fn deleted_at(&self) -> Option<DateTime<Utc>> {
1508            self.deleted_at
1509        }
1510        fn set_deleted_at(&mut self, ts: Option<DateTime<Utc>>) {
1511            self.deleted_at = ts;
1512        }
1513    }
1514
1515    #[derive(Deserialize)]
1516    struct CreateWidgetDto {
1517        name: String,
1518    }
1519
1520    #[derive(Deserialize)]
1521    struct UpdateWidgetDto {
1522        name: String,
1523    }
1524
1525    impl backbone_orm::EntityRepoMeta for Widget {
1526        fn column_types() -> HashMap<String, String> {
1527            HashMap::new()
1528        }
1529        fn search_fields() -> &'static [&'static str] {
1530            &["name"]
1531        }
1532    }
1533
1534    /// A row id whose lookup fails the way a broken database does.
1535    const BROKEN_ID: &str = "00000000-0000-0000-0000-00000000dead";
1536
1537    impl FromCreateDto<CreateWidgetDto> for Widget {
1538        fn from_create_dto(dto: CreateWidgetDto) -> ServiceResult<Self> {
1539            // A create names its owner as `owned-by:<id>`, so the create DTO stays one field.
1540            let owner_id = dto.name.strip_prefix("owned-by:").map(str::to_string);
1541            Ok(Widget {
1542                id: uuid::Uuid::new_v4().to_string(),
1543                name: dto.name,
1544                note: None,
1545                secret_hash: "h0".into(),
1546                state: "draft".into(),
1547                owner_id,
1548                deleted_at: None,
1549                created_at: Utc::now(),
1550                updated_at: Utc::now(),
1551            })
1552        }
1553    }
1554
1555    impl ApplyUpdateDto<UpdateWidgetDto> for Widget {
1556        fn apply_update(mut self, dto: UpdateWidgetDto) -> ServiceResult<Self> {
1557            self.name = dto.name;
1558            self.updated_at = Utc::now();
1559            Ok(self)
1560        }
1561    }
1562
1563    // Minimal in-memory CrudRepository
1564    struct InMemoryWidgetRepo {
1565        data: tokio::sync::Mutex<Vec<Widget>>,
1566        /// Every `update_changed` call, as (before, after).
1567        changed_writes: std::sync::Mutex<Vec<(Widget, Widget)>>,
1568        /// Every full `update` call.
1569        full_writes: std::sync::atomic::AtomicUsize,
1570        /// Make the next changed-columns write find the row gone (a concurrent delete).
1571        vanish_on_write: std::sync::atomic::AtomicBool,
1572        /// Every `bulk_create` call.
1573        bulk_creates: std::sync::atomic::AtomicUsize,
1574        /// The owner ids the caller can see; every other owner reads as hidden.
1575        visible_owners: std::sync::Mutex<Vec<String>>,
1576    }
1577
1578    impl InMemoryWidgetRepo {
1579        fn new() -> Self {
1580            Self {
1581                data: tokio::sync::Mutex::new(Vec::new()),
1582                changed_writes: std::sync::Mutex::new(Vec::new()),
1583                full_writes: std::sync::atomic::AtomicUsize::new(0),
1584                vanish_on_write: std::sync::atomic::AtomicBool::new(false),
1585                bulk_creates: std::sync::atomic::AtomicUsize::new(0),
1586                visible_owners: std::sync::Mutex::new(vec!["mine".to_string()]),
1587            }
1588        }
1589    }
1590
1591    #[async_trait]
1592    impl crate::persistence::traits::CrudRepository<Widget> for InMemoryWidgetRepo {
1593        async fn reference_visible(
1594            &self,
1595            table: &str,
1596            column: &str,
1597            value: &serde_json::Value,
1598        ) -> Result<Option<bool>, RepositoryError> {
1599            assert_eq!((table, column), ("owners", "id"));
1600            let visible = self.visible_owners.lock().unwrap();
1601            Ok(Some(value.as_str().is_some_and(|v| visible.iter().any(|o| o == v))))
1602        }
1603
1604        async fn create(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1605            self.data.lock().await.push(entity.clone());
1606            Ok(entity)
1607        }
1608
1609        async fn find_by_id(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1610            if id == BROKEN_ID {
1611                return Err(RepositoryError::DatabaseError(
1612                    "relation \"secret_schema.widgets\" is broken".into(),
1613                ));
1614            }
1615            Ok(self
1616                .data
1617                .lock()
1618                .await
1619                .iter()
1620                .find(|w| w.id == id && w.deleted_at.is_none())
1621                .cloned())
1622        }
1623
1624        async fn update(&self, entity: Widget) -> Result<Widget, RepositoryError> {
1625            self.full_writes
1626                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1627            let mut data = self.data.lock().await;
1628            data.retain(|w| w.id != entity.id);
1629            data.push(entity.clone());
1630            Ok(entity)
1631        }
1632
1633        async fn update_changed(
1634            &self,
1635            before: &Widget,
1636            after: Widget,
1637        ) -> Result<Option<Widget>, RepositoryError> {
1638            self.changed_writes
1639                .lock()
1640                .unwrap()
1641                .push((before.clone(), after.clone()));
1642            if self
1643                .vanish_on_write
1644                .load(std::sync::atomic::Ordering::SeqCst)
1645            {
1646                return Ok(None);
1647            }
1648            if after.name == "taken" {
1649                return Err(RepositoryError::AlreadyExists(
1650                    "duplicate key value violates unique constraint \"widgets_name_key\"".into(),
1651                ));
1652            }
1653            let mut data = self.data.lock().await;
1654            data.retain(|w| w.id != after.id);
1655            data.push(after.clone());
1656            Ok(Some(after))
1657        }
1658
1659        async fn soft_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1660            let mut data = self.data.lock().await;
1661            if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1662                w.deleted_at = Some(Utc::now());
1663                return Ok(true);
1664            }
1665            Ok(false)
1666        }
1667
1668        async fn find_by_id_including_deleted(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1669            Ok(self.data.lock().await.iter().find(|w| w.id == id).cloned())
1670        }
1671
1672        async fn restore(&self, id: &str) -> Result<Option<Widget>, RepositoryError> {
1673            let mut data = self.data.lock().await;
1674            if let Some(w) = data.iter_mut().find(|w| w.id == id) {
1675                w.deleted_at = None;
1676                return Ok(Some(w.clone()));
1677            }
1678            Ok(None)
1679        }
1680
1681        async fn hard_delete(&self, id: &str) -> Result<bool, RepositoryError> {
1682            let mut data = self.data.lock().await;
1683            let before = data.len();
1684            data.retain(|w| w.id != id);
1685            Ok(data.len() < before)
1686        }
1687
1688        async fn list(
1689            &self,
1690            page: u32,
1691            limit: u32,
1692        ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1693            let data = self.data.lock().await;
1694            let active: Vec<_> = data.iter().filter(|w| w.deleted_at.is_none()).cloned().collect();
1695            let total = active.len() as u64;
1696            let offset = ((page.saturating_sub(1)) as usize) * (limit as usize);
1697            let limit = limit as usize;
1698            let page = active.into_iter().skip(offset).take(limit).collect();
1699            Ok((page, total))
1700        }
1701
1702        async fn list_deleted(
1703            &self,
1704            page: u32,
1705            limit: u32,
1706        ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1707            let data = self.data.lock().await;
1708            let deleted: Vec<_> = data.iter().filter(|w| w.deleted_at.is_some()).cloned().collect();
1709            let total = deleted.len() as u64;
1710            // Capped as the generated repositories cap a page.
1711            let limit = (limit as usize).min(100);
1712            let offset = (page.saturating_sub(1) as usize) * limit;
1713            let page = deleted.into_iter().skip(offset).take(limit).collect();
1714            Ok((page, total))
1715        }
1716
1717        async fn count(&self) -> Result<u64, RepositoryError> {
1718            let data = self.data.lock().await;
1719            Ok(data.iter().filter(|w| w.deleted_at.is_none()).count() as u64)
1720        }
1721
1722        async fn count_deleted(&self) -> Result<u64, RepositoryError> {
1723            let data = self.data.lock().await;
1724            Ok(data.iter().filter(|w| w.deleted_at.is_some()).count() as u64)
1725        }
1726
1727        async fn list_deleted_filtered(
1728            &self,
1729            page: u32,
1730            limit: u32,
1731            filters: HashMap<String, String>,
1732        ) -> Result<(Vec<Widget>, u64), RepositoryError> {
1733            let (rows, _) = self.list_deleted(page, limit).await?;
1734            let rows: Vec<Widget> = rows
1735                .into_iter()
1736                .filter(|w| filters.get("name").is_none_or(|n| *n == w.name))
1737                .collect();
1738            let total = rows.len() as u64;
1739            Ok((rows, total))
1740        }
1741
1742        async fn bulk_create(&self, entities: Vec<Widget>) -> Result<Vec<Widget>, RepositoryError> {
1743            self.bulk_creates
1744                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1745            let mut data = self.data.lock().await;
1746            data.extend(entities.clone());
1747            Ok(entities)
1748        }
1749
1750        async fn empty_trash(&self) -> Result<u64, RepositoryError> {
1751            let mut data = self.data.lock().await;
1752            let before = data.len();
1753            data.retain(|w| w.deleted_at.is_none());
1754            Ok((before - data.len()) as u64)
1755        }
1756    }
1757
1758    #[tokio::test]
1759    async fn create_and_get_roundtrip() {
1760        let repo = Arc::new(InMemoryWidgetRepo::new());
1761        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1762            GenericCrudService::with_repository(repo);
1763
1764        let widget = service
1765            .create(CreateWidgetDto {
1766                name: "sprocket".into(),
1767            })
1768            .await
1769            .unwrap();
1770
1771        let found = service.get_by_id(&widget.id).await.unwrap();
1772        assert!(found.is_some());
1773        assert_eq!(found.unwrap().name, "sprocket");
1774    }
1775
1776    #[tokio::test]
1777    async fn soft_delete_hides_from_list() {
1778        let repo = Arc::new(InMemoryWidgetRepo::new());
1779        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1780            GenericCrudService::with_repository(repo);
1781
1782        let w = service.create(CreateWidgetDto { name: "w".into() }).await.unwrap();
1783        service.soft_delete(&w.id).await.unwrap();
1784
1785        let (items, _) = service.list(1, 20, Default::default()).await.unwrap();
1786        assert!(items.is_empty());
1787
1788        let (deleted, _) = service.list_deleted(1, 20).await.unwrap();
1789        assert_eq!(deleted.len(), 1);
1790    }
1791
1792    #[tokio::test]
1793    async fn update_publishes_event_and_returns_new_entity() {
1794        let repo = Arc::new(InMemoryWidgetRepo::new());
1795        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
1796            GenericCrudService::with_repository(repo);
1797
1798        let w = service.create(CreateWidgetDto { name: "old".into() }).await.unwrap();
1799        let updated = service
1800            .update(&w.id, UpdateWidgetDto { name: "new".into() })
1801            .await
1802            .unwrap();
1803        assert_eq!(updated.unwrap().name, "new");
1804    }
1805
1806    // ── Generic writes go through the changed-columns path ───────────────────
1807
1808    #[tokio::test]
1809    async fn a_patch_writes_only_through_the_changed_columns_path() {
1810        let repo = Arc::new(InMemoryWidgetRepo::new());
1811        let service: GenericCrudService<
1812            Widget,
1813            CreateWidgetDto,
1814            UpdateWidgetDto,
1815            InMemoryWidgetRepo,
1816        > = GenericCrudService::with_repository(repo.clone());
1817        let w = service
1818            .create(CreateWidgetDto { name: "old".into() })
1819            .await
1820            .unwrap();
1821
1822        let mut fields = HashMap::new();
1823        fields.insert("note".to_string(), serde_json::json!("edited"));
1824        let saved = service
1825            .partial_update(&w.id, fields)
1826            .await
1827            .unwrap()
1828            .unwrap();
1829        assert_eq!(saved.note.as_deref(), Some("edited"));
1830
1831        let writes = repo.changed_writes.lock().unwrap().clone();
1832        assert_eq!(writes.len(), 1, "a PATCH must save through update_changed");
1833        assert_eq!(writes[0].0, w, "the write starts from the row as read");
1834        assert_eq!(
1835            repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1836            0
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn a_put_writes_only_through_the_changed_columns_path() {
1842        let repo = Arc::new(InMemoryWidgetRepo::new());
1843        let service: GenericCrudService<
1844            Widget,
1845            CreateWidgetDto,
1846            UpdateWidgetDto,
1847            InMemoryWidgetRepo,
1848        > = GenericCrudService::with_repository(repo.clone());
1849        let w = service
1850            .create(CreateWidgetDto { name: "old".into() })
1851            .await
1852            .unwrap();
1853        service
1854            .update(&w.id, UpdateWidgetDto { name: "new".into() })
1855            .await
1856            .unwrap()
1857            .unwrap();
1858        assert_eq!(repo.changed_writes.lock().unwrap().len(), 1);
1859        assert_eq!(
1860            repo.full_writes.load(std::sync::atomic::Ordering::SeqCst),
1861            0
1862        );
1863    }
1864
1865    #[tokio::test]
1866    async fn a_write_whose_row_vanished_meanwhile_answers_not_found() {
1867        let repo = Arc::new(InMemoryWidgetRepo::new());
1868        let service: GenericCrudService<
1869            Widget,
1870            CreateWidgetDto,
1871            UpdateWidgetDto,
1872            InMemoryWidgetRepo,
1873        > = GenericCrudService::with_repository(repo.clone());
1874        let w = service
1875            .create(CreateWidgetDto { name: "old".into() })
1876            .await
1877            .unwrap();
1878        repo.vanish_on_write
1879            .store(true, std::sync::atomic::Ordering::SeqCst);
1880
1881        let mut fields = HashMap::new();
1882        fields.insert("note".to_string(), serde_json::json!("edited"));
1883        assert!(service
1884            .partial_update(&w.id, fields)
1885            .await
1886            .unwrap()
1887            .is_none());
1888        assert!(service
1889            .update(&w.id, UpdateWidgetDto { name: "new".into() })
1890            .await
1891            .unwrap()
1892            .is_none());
1893    }
1894
1895    // ── A transition may move the state field, and only that ─────────────────
1896
1897    #[tokio::test]
1898    async fn a_transition_moves_its_protected_state_field() {
1899        let service = svc();
1900        let w = service
1901            .create(CreateWidgetDto { name: "w".into() })
1902            .await
1903            .unwrap();
1904        let mut fields = HashMap::new();
1905        fields.insert("state".to_string(), serde_json::json!("active"));
1906        let saved =
1907            crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1908                .await
1909                .expect("a verb moves its own state field")
1910                .unwrap();
1911        assert_eq!(saved.state, "active");
1912    }
1913
1914    #[tokio::test]
1915    async fn a_transition_still_cannot_move_another_protected_field() {
1916        let service = svc();
1917        let w = service
1918            .create(CreateWidgetDto { name: "w".into() })
1919            .await
1920            .unwrap();
1921        let mut fields = HashMap::new();
1922        fields.insert("state".to_string(), serde_json::json!("active"));
1923        fields.insert("secret_hash".to_string(), serde_json::json!("h1"));
1924        let err =
1925            crate::write_guard::as_transition("activate", service.partial_update(&w.id, fields))
1926                .await
1927                .unwrap_err();
1928        match err {
1929            ServiceError::Violations(v) => {
1930                assert_eq!(v.len(), 1, "{v:?}");
1931                assert_eq!(
1932                    (v[0].path.as_str(), v[0].code.as_str()),
1933                    ("secret_hash", "field_not_writable")
1934                );
1935            }
1936            other => panic!("expected a violation, got {other:?}"),
1937        }
1938    }
1939
1940    #[tokio::test]
1941    async fn a_generic_write_still_cannot_move_the_state_field() {
1942        let service = svc();
1943        let w = service
1944            .create(CreateWidgetDto { name: "w".into() })
1945            .await
1946            .unwrap();
1947        let mut fields = HashMap::new();
1948        fields.insert("state".to_string(), serde_json::json!("active"));
1949        let err = service.partial_update(&w.id, fields).await.unwrap_err();
1950        assert!(
1951            matches!(err, ServiceError::Violations(ref v) if v[0].path == "state"),
1952            "{err:?}"
1953        );
1954    }
1955
1956    // ── Batch operations ──────────────────────────────────────────────────────
1957
1958    fn svc() -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
1959        GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()))
1960    }
1961
1962    #[tokio::test]
1963    async fn bulk_soft_delete_success() {
1964        let service = svc();
1965        let mut ids = Vec::new();
1966        for n in 0..3 {
1967            ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1968        }
1969        let n = service.bulk_soft_delete(ids).await.unwrap();
1970        assert_eq!(n, 3);
1971        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1972        assert!(active.is_empty());
1973    }
1974
1975    #[tokio::test]
1976    async fn bulk_soft_delete_is_all_or_nothing_on_missing_id() {
1977        let service = svc();
1978        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
1979        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
1980
1981        // One id is missing → whole batch must be rejected with nothing deleted.
1982        let res = service
1983            .bulk_soft_delete(vec![a.id.clone(), b.id.clone(), "does-not-exist".into()])
1984            .await;
1985        assert!(res.is_err());
1986
1987        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
1988        assert_eq!(active.len(), 2, "no rows should have been deleted");
1989    }
1990
1991    #[tokio::test]
1992    async fn bulk_restore_and_restore_all() {
1993        let service = svc();
1994        let mut ids = Vec::new();
1995        for n in 0..3 {
1996            ids.push(service.create(CreateWidgetDto { name: format!("w{n}") }).await.unwrap().id);
1997        }
1998        service.bulk_soft_delete(ids.clone()).await.unwrap();
1999
2000        // Restore two explicitly.
2001        let restored = service.bulk_restore(vec![ids[0].clone(), ids[1].clone()]).await.unwrap();
2002        assert_eq!(restored.len(), 2);
2003
2004        // Restore the remaining one via restore_all.
2005        let n = service.restore_all().await.unwrap();
2006        assert_eq!(n, 1);
2007
2008        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2009        assert_eq!(active.len(), 3);
2010    }
2011
2012    #[tokio::test]
2013    async fn bulk_update_is_all_or_nothing_on_missing_id() {
2014        let service = svc();
2015        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2016
2017        let res = service
2018            .bulk_update(vec![
2019                (a.id.clone(), UpdateWidgetDto { name: "A2".into() }),
2020                ("missing".into(), UpdateWidgetDto { name: "X".into() }),
2021            ])
2022            .await;
2023        assert!(res.is_err());
2024
2025        // The valid row must be untouched because the batch aborted pre-write.
2026        let still = service.get_by_id(&a.id).await.unwrap().unwrap();
2027        assert_eq!(still.name, "a");
2028    }
2029
2030    #[tokio::test]
2031    async fn bulk_partial_update_success() {
2032        let service = svc();
2033        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2034        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2035
2036        let mut patch_a = std::collections::HashMap::new();
2037        patch_a.insert("name".to_string(), serde_json::json!("a2"));
2038        let mut patch_b = std::collections::HashMap::new();
2039        patch_b.insert("name".to_string(), serde_json::json!("b2"));
2040
2041        let saved = service
2042            .bulk_partial_update(vec![(a.id.clone(), patch_a), (b.id.clone(), patch_b)])
2043            .await
2044            .unwrap();
2045        assert_eq!(saved.len(), 2);
2046        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a2");
2047        assert_eq!(service.get_by_id(&b.id).await.unwrap().unwrap().name, "b2");
2048    }
2049
2050    #[tokio::test]
2051    async fn bulk_soft_delete_tolerates_duplicate_ids() {
2052        let service = svc();
2053        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2054        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2055
2056        // A repeated id must not fail the batch: ids are de-duplicated first.
2057        let n = service
2058            .bulk_soft_delete(vec![a.id.clone(), a.id.clone(), b.id.clone()])
2059            .await
2060            .unwrap();
2061        assert_eq!(n, 2, "two distinct rows soft-deleted");
2062
2063        let (active, _) = service.list(1, 20, Default::default()).await.unwrap();
2064        assert!(active.is_empty());
2065    }
2066
2067    #[tokio::test]
2068    async fn bulk_update_rejects_duplicate_ids() {
2069        let service = svc();
2070        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2071
2072        // The same id mapped to two payloads is ambiguous → rejected, nothing written.
2073        let res = service
2074            .bulk_update(vec![
2075                (a.id.clone(), UpdateWidgetDto { name: "first".into() }),
2076                (a.id.clone(), UpdateWidgetDto { name: "second".into() }),
2077            ])
2078            .await;
2079        assert!(res.is_err());
2080        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2081    }
2082
2083    #[tokio::test]
2084    async fn bulk_soft_delete_rejects_oversized_batch() {
2085        let service = svc();
2086        let ids: Vec<String> = (0..MAX_BATCH_SIZE + 1).map(|n| format!("id-{n}")).collect();
2087        // Enforced at the service layer, independent of any HTTP handler.
2088        assert!(service.bulk_soft_delete(ids).await.is_err());
2089    }
2090
2091    // ── Generic writes may not change protected or unknown fields ─────────
2092
2093    /// A full update that carries the protected field, the way a form that
2094    /// replaces the whole record would.
2095    struct RekeyWidgetDto {
2096        name: String,
2097        secret_hash: String,
2098    }
2099
2100    impl ApplyUpdateDto<RekeyWidgetDto> for Widget {
2101        fn apply_update(mut self, dto: RekeyWidgetDto) -> ServiceResult<Self> {
2102            self.name = dto.name;
2103            self.secret_hash = dto.secret_hash;
2104            Ok(self)
2105        }
2106    }
2107
2108    type RekeyService = GenericCrudService<Widget, CreateWidgetDto, RekeyWidgetDto, InMemoryWidgetRepo>;
2109
2110    fn patch(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
2111        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
2112    }
2113
2114    /// The refusal's message, whether the service refused with named
2115    /// violations or with a plain validation sentence.
2116    fn validation_message(res: ServiceResult<impl std::fmt::Debug>) -> String {
2117        match res {
2118            Err(ServiceError::Validation(m)) => m,
2119            Err(e @ ServiceError::Violations(_)) => e.to_string(),
2120            other => panic!("expected a validation error, got {other:?}"),
2121        }
2122    }
2123
2124    #[tokio::test]
2125    async fn patch_rejects_a_field_the_entity_does_not_have() {
2126        let service = svc();
2127        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2128
2129        let msg = validation_message(service.partial_update(&w.id, patch(&[("nmae", serde_json::json!("b"))])).await);
2130        assert!(msg.contains("unknown_field") && msg.contains("nmae"), "{msg}");
2131        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
2132    }
2133
2134    #[tokio::test]
2135    async fn patch_rejects_changing_the_id() {
2136        let service = svc();
2137        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2138
2139        let msg = validation_message(service.partial_update(&w.id, patch(&[("id", serde_json::json!("other"))])).await);
2140        assert!(msg.contains("field_not_writable") && msg.contains("id"), "{msg}");
2141        assert!(service.get_by_id(&w.id).await.unwrap().is_some());
2142    }
2143
2144    #[tokio::test]
2145    async fn patch_rejects_changing_a_field_the_entity_protects() {
2146        let service = svc();
2147        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2148
2149        let msg = validation_message(
2150            service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await,
2151        );
2152        assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2153        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().secret_hash, "h0");
2154    }
2155
2156    #[tokio::test]
2157    async fn patch_accepts_a_protected_field_echoed_unchanged() {
2158        let service = svc();
2159        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2160
2161        let saved = service
2162            .partial_update(&w.id, patch(&[("name", serde_json::json!("b")), ("secret_hash", serde_json::json!("h0"))]))
2163            .await
2164            .unwrap()
2165            .unwrap();
2166        assert_eq!(saved.name, "b");
2167    }
2168
2169    #[tokio::test]
2170    async fn patch_sets_and_clears_an_optional_field_that_is_not_serialized_when_empty() {
2171        let service = svc();
2172        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2173
2174        let set = service.partial_update(&w.id, patch(&[("note", serde_json::json!("hi"))])).await.unwrap().unwrap();
2175        assert_eq!(set.note.as_deref(), Some("hi"));
2176        let cleared = service.partial_update(&w.id, patch(&[("note", serde_json::Value::Null)])).await.unwrap().unwrap();
2177        assert_eq!(cleared.note, None);
2178    }
2179
2180    #[tokio::test]
2181    async fn put_rejects_changing_a_field_the_entity_protects() {
2182        let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2183        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2184
2185        let msg = validation_message(
2186            service.update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h1".into() }).await,
2187        );
2188        assert!(msg.contains("field_not_writable") && msg.contains("secret_hash"), "{msg}");
2189
2190        // The same form echoing the stored value is an ordinary edit.
2191        let saved = service
2192            .update(&w.id, RekeyWidgetDto { name: "b".into(), secret_hash: "h0".into() })
2193            .await
2194            .unwrap()
2195            .unwrap();
2196        assert_eq!(saved.name, "b");
2197    }
2198
2199    #[tokio::test]
2200    async fn bulk_update_rejects_a_protected_change_before_writing_any_row() {
2201        let service: RekeyService = GenericCrudService::with_repository(Arc::new(InMemoryWidgetRepo::new()));
2202        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2203        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2204
2205        let res = service
2206            .bulk_update(vec![
2207                (a.id.clone(), RekeyWidgetDto { name: "a2".into(), secret_hash: "h0".into() }),
2208                (b.id.clone(), RekeyWidgetDto { name: "b2".into(), secret_hash: "h1".into() }),
2209            ])
2210            .await;
2211        assert!(validation_message(res).contains("field_not_writable"));
2212        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2213    }
2214
2215    #[tokio::test]
2216    async fn bulk_partial_update_rejects_a_protected_change_before_writing_any_row() {
2217        let service = svc();
2218        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2219        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2220
2221        let res = service
2222            .bulk_partial_update(vec![
2223                (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2224                (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2225            ])
2226            .await;
2227        assert!(validation_message(res).contains("field_not_writable"));
2228        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2229    }
2230
2231    #[tokio::test]
2232    async fn a_bulk_preview_says_what_each_row_would_do_and_writes_nothing() {
2233        let service = svc();
2234        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2235        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap();
2236
2237        let rows = service
2238            .preview_bulk_partial_update(vec![
2239                (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2240                (b.id.clone(), patch(&[("secret_hash", serde_json::json!("h1"))])),
2241                ("missing".into(), patch(&[("name", serde_json::json!("x"))])),
2242            ])
2243            .await
2244            .unwrap();
2245        assert_eq!(rows.len(), 3);
2246        let (before, after) = rows[0].outcome.as_ref().unwrap();
2247        assert_eq!((before.name.as_str(), after.name.as_str()), ("a", "a2"));
2248        let refused = rows[1].outcome.as_ref().err().unwrap().to_string();
2249        assert!(refused.contains("field_not_writable"), "{refused}");
2250        assert!(rows[2].outcome.is_err(), "a row that is not there is refused, not dropped");
2251        // One refused row does not refuse the others, and nothing was written.
2252        assert_eq!(service.get_by_id(&a.id).await.unwrap().unwrap().name, "a");
2253    }
2254
2255    #[tokio::test]
2256    async fn a_bulk_preview_refuses_a_batch_the_write_would_refuse_whole() {
2257        let service = svc();
2258        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2259        let res = service
2260            .preview_bulk_partial_update(vec![
2261                (a.id.clone(), patch(&[("name", serde_json::json!("a2"))])),
2262                (a.id.clone(), patch(&[("name", serde_json::json!("a3"))])),
2263            ])
2264            .await;
2265        assert!(validation_message(res.map(|r| r.len())).contains("duplicate id"));
2266    }
2267
2268    #[tokio::test]
2269    async fn bulk_partial_update_rejects_an_unknown_field() {
2270        let service = svc();
2271        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2272
2273        let res = service.bulk_partial_update(vec![(a.id.clone(), patch(&[("colour", serde_json::json!("red"))]))]).await;
2274        assert!(validation_message(res).contains("unknown_field"));
2275    }
2276
2277    // ── Events and trash lifecycle ────────────────────────────────────────
2278
2279    #[derive(Default)]
2280    struct RecordingPublisher {
2281        kinds: tokio::sync::Mutex<Vec<&'static str>>,
2282    }
2283
2284    #[async_trait]
2285    impl CrudEventPublisher<Widget> for RecordingPublisher {
2286        async fn publish(&self, event: CrudEvent<Widget>) -> Result<(), backbone_messaging::EventError> {
2287            let kind = match event {
2288                CrudEvent::Updated { .. } => "updated",
2289                CrudEvent::Patched { .. } => "patched",
2290                _ => "other",
2291            };
2292            self.kinds.lock().await.push(kind);
2293            Ok(())
2294        }
2295    }
2296
2297    #[tokio::test]
2298    async fn patch_publishes_an_updated_event() {
2299        let publisher = Arc::new(RecordingPublisher::default());
2300        let service = svc().with_event_publisher(publisher.clone());
2301        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2302        publisher.kinds.lock().await.clear();
2303
2304        service.partial_update(&w.id, patch(&[("name", serde_json::json!("b"))])).await.unwrap();
2305        assert_eq!(*publisher.kinds.lock().await, vec!["updated"]);
2306    }
2307
2308    /// A lifecycle that refuses every trash operation, so a test can tell
2309    /// whether the operation consulted it.
2310    struct RefuseTrash;
2311
2312    #[async_trait]
2313    impl ServiceLifecycle<Widget> for RefuseTrash {
2314        async fn before_restore(&self, _entity: &Widget) -> ServiceResult<()> {
2315            Err(ServiceError::Validation("restore refused".into()))
2316        }
2317        async fn before_hard_delete(&self, _entity: &Widget) -> ServiceResult<()> {
2318            Err(ServiceError::Validation("hard delete refused".into()))
2319        }
2320        async fn before_restore_all(&self) -> ServiceResult<()> {
2321            Err(ServiceError::Validation("restore all refused".into()))
2322        }
2323        async fn before_empty_trash(&self) -> ServiceResult<()> {
2324            Err(ServiceError::Validation("empty trash refused".into()))
2325        }
2326    }
2327
2328    #[tokio::test]
2329    async fn every_trash_operation_consults_the_lifecycle() {
2330        let repo = Arc::new(InMemoryWidgetRepo::new());
2331        let service: GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> =
2332            GenericCrudService::with_lifecycle(repo, Arc::new(RefuseTrash));
2333        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2334        service.soft_delete(&w.id).await.unwrap();
2335
2336        assert!(service.restore(&w.id).await.is_err(), "restore");
2337        assert!(service.bulk_restore(vec![w.id.clone()]).await.is_err(), "bulk restore");
2338        assert!(service.restore_all().await.is_err(), "restore all");
2339        assert!(service.hard_delete(&w.id).await.is_err(), "hard delete");
2340        assert!(service.bulk_permanent_delete(vec![w.id.clone()]).await.is_err(), "bulk permanent delete");
2341        assert!(service.empty_trash().await.is_err(), "empty trash");
2342
2343        // Nothing moved: the row is still in the trash.
2344        let still = service.get_deleted_by_id(&w.id).await.unwrap().unwrap();
2345        assert!(still.deleted_at.is_some());
2346    }
2347
2348    // ── Named violations and the write guard ──────────────────────────────
2349
2350    #[tokio::test]
2351    async fn a_refused_field_is_a_violation_naming_its_path_and_code() {
2352        let service = svc();
2353        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2354
2355        let err = service
2356            .partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1")), ("colour", serde_json::json!("red"))]))
2357            .await
2358            .unwrap_err();
2359        let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2360        let pairs: Vec<(&str, &str)> = v.iter().map(|x| (x.path.as_str(), x.code.as_str())).collect();
2361        assert_eq!(pairs, vec![("colour", "unknown_field")]);
2362        // The sentence clients already read is unchanged.
2363        assert!(err.to_string().starts_with("validation failed: unknown_field: `colour`"), "{err}");
2364
2365        let err = service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("h1"))])).await.unwrap_err();
2366        let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2367        assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("secret_hash", "field_not_writable"));
2368    }
2369
2370    /// Refuses any write whose resulting widget is named `forbidden`, and
2371    /// shadow-reports any named `watched`. Records every kind it was asked about.
2372    #[derive(Default)]
2373    struct NameGuard {
2374        seen: std::sync::Mutex<Vec<crate::write_guard::WriteKind>>,
2375    }
2376
2377    #[async_trait]
2378    impl crate::write_guard::WriteGuard<Widget> for NameGuard {
2379        async fn check(&self, ctx: &crate::write_guard::WriteCtx<'_, Widget>) -> crate::write_guard::GuardOutcome {
2380            self.seen.lock().unwrap().push(ctx.kind);
2381            let row = ctx.after.or(ctx.before).expect("a write concerns a row");
2382            let mut out = crate::write_guard::GuardOutcome::default();
2383            match row.name.as_str() {
2384                "forbidden" => out.refuse.push(crate::violation::Violation::new("name", "name_forbidden", "this name is refused")),
2385                "watched" => out.shadow.push(crate::violation::Violation::new("name", "name_watched", "would be refused")),
2386                _ => {}
2387            }
2388            out
2389        }
2390    }
2391
2392    fn guarded(guard: Arc<NameGuard>) -> GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo> {
2393        svc().with_guard(guard)
2394    }
2395
2396    #[tokio::test]
2397    async fn a_guard_refusal_stops_create_update_and_patch_with_nothing_written() {
2398        let guard = Arc::new(NameGuard::default());
2399        let service = guarded(guard.clone());
2400
2401        let err = service.create(CreateWidgetDto { name: "forbidden".into() }).await.unwrap_err();
2402        assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2403        assert!(service.list(1, 20, Default::default()).await.unwrap().0.is_empty(), "nothing created");
2404
2405        let w = service.create(CreateWidgetDto { name: "ok".into() }).await.unwrap();
2406        assert!(service.update(&w.id, UpdateWidgetDto { name: "forbidden".into() }).await.is_err());
2407        assert!(service.partial_update(&w.id, patch(&[("name", serde_json::json!("forbidden"))])).await.is_err());
2408        assert!(service
2409            .bulk_partial_update(vec![(w.id.clone(), patch(&[("name", serde_json::json!("forbidden"))]))])
2410            .await
2411            .is_err());
2412        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "ok");
2413    }
2414
2415    #[tokio::test]
2416    async fn a_shadowed_rule_lets_the_write_through() {
2417        let service = guarded(Arc::new(NameGuard::default()));
2418        let w = service.create(CreateWidgetDto { name: "watched".into() }).await.unwrap();
2419        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "watched");
2420    }
2421
2422    #[tokio::test]
2423    async fn the_guard_is_asked_about_every_write_kind() {
2424        use crate::write_guard::WriteKind::*;
2425        let guard = Arc::new(NameGuard::default());
2426        let service = guarded(guard.clone());
2427        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2428        service.update(&w.id, UpdateWidgetDto { name: "b".into() }).await.unwrap();
2429        service.partial_update(&w.id, patch(&[("name", serde_json::json!("c"))])).await.unwrap();
2430        service.soft_delete(&w.id).await.unwrap();
2431        service.restore(&w.id).await.unwrap();
2432        service.soft_delete(&w.id).await.unwrap();
2433        service.hard_delete(&w.id).await.unwrap();
2434        assert_eq!(*guard.seen.lock().unwrap(), vec![Create, Update, Update, Delete, Restore, Delete, HardDelete]);
2435    }
2436
2437    #[test]
2438    fn the_generic_service_hands_its_violations_to_the_http_layer() {
2439        use crate::http::CrudService;
2440        type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2441        let v = vec![crate::violation::Violation::new("name", "x", "y")];
2442        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::Violations(v.clone())), Some(v.clone()));
2443        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&ServiceError::NotFound), None);
2444        // A constraint the database refused with reaches the HTTP layer as the same 422.
2445        let db = ServiceError::Repository(RepositoryError::Violations(v.clone()));
2446        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::violations_of(&db), Some(v));
2447    }
2448
2449    // ── References point only at rows the caller can see ─────────────────
2450
2451    fn only_violation(res: ServiceResult<impl std::fmt::Debug>) -> crate::violation::Violation {
2452        match res {
2453            Err(ServiceError::Violations(mut v)) if v.len() == 1 => v.remove(0),
2454            other => panic!("expected one violation, got {other:?}"),
2455        }
2456    }
2457
2458    #[tokio::test]
2459    async fn a_reference_to_a_hidden_row_is_refused_on_every_write_kind() {
2460        let service = svc();
2461        // Create: a hidden owner is refused as missing, and nothing is written.
2462        let v = only_violation(service.create(CreateWidgetDto { name: "owned-by:theirs".into() }).await);
2463        assert_eq!((v.path.as_str(), v.code.as_str()), ("owner_id", "reference_missing"));
2464        assert!(!v.message.contains("owners"), "{}", v.message);
2465        assert_eq!(service.count().await.unwrap(), 0);
2466        // A visible owner and no owner are let through.
2467        let w = service.create(CreateWidgetDto { name: "owned-by:mine".into() }).await.unwrap();
2468        service.create(CreateWidgetDto { name: "plain".into() }).await.unwrap();
2469
2470        // PATCH that moves the reference to a hidden row.
2471        let v = only_violation(service.partial_update(&w.id, patch(&[("owner_id", serde_json::json!("theirs"))])).await);
2472        assert_eq!((v.path.as_str(), v.code.as_str()), ("owner_id", "reference_missing"));
2473        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().owner_id.as_deref(), Some("mine"));
2474        // Bulk create and bulk PATCH name the item.
2475        let v = only_violation(
2476            service
2477                .bulk_create(vec![CreateWidgetDto { name: "x".into() }, CreateWidgetDto { name: "owned-by:theirs".into() }])
2478                .await,
2479        );
2480        assert_eq!((v.path.as_str(), v.code.as_str()), ("1/owner_id", "reference_missing"));
2481        let v = only_violation(
2482            service.bulk_partial_update(vec![(w.id.clone(), patch(&[("owner_id", serde_json::json!("theirs"))]))]).await,
2483        );
2484        assert_eq!(v.path, "0/owner_id");
2485    }
2486
2487    #[tokio::test]
2488    async fn an_unchanged_reference_is_not_checked_again() {
2489        let service = svc();
2490        let w = service.create(CreateWidgetDto { name: "owned-by:mine".into() }).await.unwrap();
2491        // The owner becomes invisible; an edit that leaves it alone still goes through.
2492        service.repository().visible_owners.lock().unwrap().clear();
2493        let saved = service.partial_update(&w.id, patch(&[("note", serde_json::json!("x"))])).await.unwrap().unwrap();
2494        assert_eq!(saved.owner_id.as_deref(), Some("mine"));
2495        let saved = service.update(&w.id, UpdateWidgetDto { name: "renamed".into() }).await.unwrap().unwrap();
2496        assert_eq!(saved.name, "renamed");
2497    }
2498
2499    // ── A patch value the entity cannot hold names its field ─────────────
2500
2501    #[tokio::test]
2502    async fn a_patch_of_the_wrong_type_or_a_null_required_field_is_a_violation_on_its_path() {
2503        let service = svc();
2504        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2505        let v = only_violation(service.partial_update(&w.id, patch(&[("name", serde_json::json!(5))])).await);
2506        assert_eq!((v.path.as_str(), v.code.as_str()), ("name", "invalid_value"));
2507        let v = only_violation(service.partial_update(&w.id, patch(&[("name", serde_json::Value::Null)])).await);
2508        assert_eq!((v.path.as_str(), v.code.as_str()), ("name", "required"));
2509        assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a");
2510    }
2511
2512    #[tokio::test]
2513    async fn a_patch_setting_an_empty_protected_field_is_refused() {
2514        // An `@exclude_from_dto` field reaches the generic service as a protected one.
2515        let service = svc();
2516        let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2517        let v = only_violation(service.partial_update(&w.id, patch(&[("secret_hash", serde_json::json!("set"))])).await);
2518        assert_eq!((v.path.as_str(), v.code.as_str()), ("secret_hash", "field_not_writable"));
2519    }
2520
2521    #[tokio::test]
2522    async fn emptying_a_trash_larger_than_one_page_reaches_every_row() {
2523        let service = svc();
2524        let mut ids = Vec::new();
2525        for i in 0..150 {
2526            ids.push(service.create(CreateWidgetDto { name: format!("w{i}") }).await.unwrap().id);
2527        }
2528        service.bulk_soft_delete(ids).await.unwrap();
2529        // The in-memory repository caps a page at 100 as the generated ones do, so this proves
2530        // the trash is walked page by page rather than read as one oversized page.
2531        assert_eq!(service.empty_trash().await.unwrap(), 150);
2532        assert_eq!(service.count_deleted().await.unwrap(), 0);
2533    }
2534
2535    #[test]
2536    fn the_entity_name_never_carries_the_module_path() {
2537        use crate::http::CrudService;
2538        type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2539        assert_eq!(<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::entity_name(), "Widget");
2540    }
2541
2542    // ── Trash-wide writes ask the guard about every row ───────────────────
2543
2544    /// Trash two widgets, one renamed behind the service's back to a name the guard refuses.
2545    async fn trash_with_a_refused_row(
2546        service: &GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>,
2547    ) -> (String, String) {
2548        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2549        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2550        service.bulk_soft_delete(vec![a.clone(), b.clone()]).await.unwrap();
2551        let mut data = service.repository().data.lock().await;
2552        data.iter_mut().find(|w| w.id == b).unwrap().name = "forbidden".into();
2553        (a, b)
2554    }
2555
2556    #[tokio::test]
2557    async fn restore_all_asks_the_guard_about_every_row_and_restores_none_when_one_is_refused() {
2558        let guard = Arc::new(NameGuard::default());
2559        let service = guarded(guard.clone());
2560        trash_with_a_refused_row(&service).await;
2561
2562        let err = service.restore_all().await.unwrap_err();
2563        assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2564        assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing restored");
2565        assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::Restore));
2566    }
2567
2568    #[tokio::test]
2569    async fn empty_trash_asks_the_guard_about_every_row_and_deletes_none_when_one_is_refused() {
2570        let guard = Arc::new(NameGuard::default());
2571        let service = guarded(guard.clone());
2572        trash_with_a_refused_row(&service).await;
2573
2574        let err = service.empty_trash().await.unwrap_err();
2575        assert!(matches!(&err, ServiceError::Violations(v) if v[0].code == "name_forbidden"), "{err:?}");
2576        assert_eq!(service.count_deleted().await.unwrap(), 2, "nothing deleted");
2577        assert!(guard.seen.lock().unwrap().contains(&crate::write_guard::WriteKind::HardDelete));
2578
2579        // Without the refused row the trash empties.
2580        let service = svc();
2581        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2582        service.soft_delete(&a).await.unwrap();
2583        assert_eq!(service.empty_trash().await.unwrap(), 1);
2584        assert_eq!(service.count_deleted().await.unwrap(), 0);
2585    }
2586
2587    // ── Upsert refuses instead of duplicating ──────────────────────────────
2588
2589    #[tokio::test]
2590    async fn upsert_refuses_rather_than_creating_a_duplicate() {
2591        use crate::http::CrudService;
2592        type S = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2593        let service = svc();
2594        assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2595        assert!(service.upsert(CreateWidgetDto { name: "a".into() }).await.is_err());
2596        assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2597        assert!(!<S as CrudService<Widget, CreateWidgetDto, UpdateWidgetDto>>::supports_upsert());
2598    }
2599
2600    // ── Bulk create: capped, one repository call, the failed item named ───
2601
2602    #[tokio::test]
2603    async fn bulk_create_is_one_repository_write_and_names_the_refused_item() {
2604        let service = guarded(Arc::new(NameGuard::default()));
2605        let names = ["a", "forbidden", "c"];
2606        let err = service
2607            .bulk_create(names.iter().map(|n| CreateWidgetDto { name: n.to_string() }).collect())
2608            .await
2609            .unwrap_err();
2610        let ServiceError::Violations(v) = &err else { panic!("expected violations, got {err:?}") };
2611        assert_eq!((v[0].path.as_str(), v[0].code.as_str()), ("1/name", "name_forbidden"));
2612        assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2613
2614        let created = service
2615            .bulk_create(vec![CreateWidgetDto { name: "a".into() }, CreateWidgetDto { name: "b".into() }])
2616            .await
2617            .unwrap();
2618        assert_eq!(created.len(), 2);
2619        assert_eq!(
2620            service.repository().bulk_creates.load(std::sync::atomic::Ordering::SeqCst),
2621            1,
2622            "the batch is written by one all-or-nothing repository call"
2623        );
2624    }
2625
2626    #[tokio::test]
2627    async fn bulk_create_refuses_an_oversized_batch() {
2628        let service = svc();
2629        let dtos = (0..=MAX_BATCH_SIZE).map(|n| CreateWidgetDto { name: format!("w{n}") }).collect();
2630        assert!(matches!(service.bulk_create(dtos).await, Err(ServiceError::Validation(_))));
2631        assert_eq!(service.count().await.unwrap(), 0);
2632    }
2633
2634    // ── Bulk updates write only the changed columns ────────────────────────
2635
2636    #[tokio::test]
2637    async fn bulk_updates_write_through_the_changed_columns_path() {
2638        let service = svc();
2639        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2640        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2641        service
2642            .bulk_update(vec![
2643                (a.clone(), UpdateWidgetDto { name: "a2".into() }),
2644                (b.clone(), UpdateWidgetDto { name: "b2".into() }),
2645            ])
2646            .await
2647            .unwrap();
2648        service
2649            .bulk_partial_update(vec![(a.clone(), patch(&[("name", serde_json::json!("a3"))]))])
2650            .await
2651            .unwrap();
2652        let repo = service.repository();
2653        assert_eq!(repo.changed_writes.lock().unwrap().len(), 3);
2654        assert_eq!(repo.full_writes.load(std::sync::atomic::Ordering::SeqCst), 0, "no full-row write");
2655        assert_eq!(service.get_by_id(&a).await.unwrap().unwrap().name, "a3");
2656    }
2657
2658    // ── The trash list takes the list's filters ────────────────────────────
2659
2660    #[tokio::test]
2661    async fn the_trash_list_applies_filters() {
2662        let service = svc();
2663        let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2664        let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2665        service.bulk_soft_delete(vec![a, b]).await.unwrap();
2666        let filters = HashMap::from([("name".to_string(), "b".to_string())]);
2667        let (rows, total) = service.list_deleted_filtered(1, 20, filters).await.unwrap();
2668        assert_eq!((rows.len(), total), (1, 1));
2669        assert_eq!(rows[0].name, "b");
2670    }
2671
2672    // ── The generic HTTP surface ───────────────────────────────────────────
2673
2674    mod http_surface {
2675        use super::*;
2676        use axum::body::Body;
2677        use axum::http::{Request, StatusCode};
2678        use tower::ServiceExt;
2679
2680        type Svc = GenericCrudService<Widget, CreateWidgetDto, UpdateWidgetDto, InMemoryWidgetRepo>;
2681
2682        fn app() -> (axum::Router, Arc<Svc>) {
2683            let service = Arc::new(svc());
2684            let router = crate::http::BackboneCrudHandler::<Svc, Widget, CreateWidgetDto, UpdateWidgetDto, Widget>::routes(
2685                service.clone(),
2686                "/widgets",
2687            );
2688            (router, service)
2689        }
2690
2691        async fn call(app: &axum::Router, method: &str, uri: &str, body: Option<&str>) -> (StatusCode, serde_json::Value) {
2692            let mut req = Request::builder().method(method).uri(uri);
2693            if body.is_some() {
2694                req = req.header("content-type", "application/json");
2695            }
2696            let req = req.body(Body::from(body.unwrap_or("").to_string())).unwrap();
2697            let res = app.clone().oneshot(req).await.unwrap();
2698            let status = res.status();
2699            let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
2700            let json = if bytes.is_empty() {
2701                serde_json::Value::Null
2702            } else {
2703                serde_json::from_slice(&bytes).unwrap_or_else(|_| {
2704                    panic!("not a JSON body: {}", String::from_utf8_lossy(&bytes))
2705                })
2706            };
2707            (status, json)
2708        }
2709
2710        #[tokio::test]
2711        async fn a_malformed_path_id_is_a_400_naming_the_id() {
2712            let (app, _) = app();
2713            for (method, uri) in [("GET", "/widgets/not-a-uuid"), ("DELETE", "/widgets/not-a-uuid")] {
2714                let (status, body) = call(&app, method, uri, None).await;
2715                assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {uri}");
2716                assert_eq!(body["violations"][0]["code"], "invalid_id");
2717                assert_eq!(body["violations"][0]["path"], "id");
2718            }
2719        }
2720
2721        #[tokio::test]
2722        async fn a_missing_record_answers_a_coded_404_without_the_type_path() {
2723            let (app, _) = app();
2724            let missing = uuid::Uuid::new_v4();
2725            for (method, uri) in [
2726                ("GET", format!("/widgets/{missing}")),
2727                ("GET", format!("/widgets/{missing}/deleted")),
2728                ("DELETE", format!("/widgets/trash/{missing}")),
2729            ] {
2730                let (status, body) = call(&app, method, &uri, None).await;
2731                assert_eq!(status, StatusCode::NOT_FOUND, "{method} {uri}");
2732                assert_eq!(body["violations"][0]["code"], "not_found", "{body}");
2733                assert_eq!(body["error"], crate::http::NOT_FOUND_MESSAGE);
2734                assert!(!body.to_string().contains("::"), "{body}");
2735            }
2736        }
2737
2738        #[tokio::test]
2739        async fn a_list_page_is_clamped_and_page_zero_reads_as_the_first() {
2740            let (app, service) = app();
2741            service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2742            let (status, body) = call(&app, "GET", "/widgets?limit=1000&page=0", None).await;
2743            assert_eq!(status, StatusCode::OK, "{body}");
2744            assert_eq!(body["meta"]["limit"], crate::http::MAX_PER_PAGE);
2745            assert_eq!(body["meta"]["page"], 1);
2746            let (status, body) = call(&app, "GET", "/widgets/trash?limit=1000&page=0", None).await;
2747            assert_eq!(status, StatusCode::OK, "{body}");
2748            assert_eq!(body["meta"]["limit"], crate::http::MAX_PER_PAGE);
2749            // A page too deep is refused, coded.
2750            let (status, body) = call(&app, "GET", "/widgets?limit=100&page=1000", None).await;
2751            assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
2752            assert_eq!(body["violations"][0]["code"], "invalid_request");
2753        }
2754
2755        #[tokio::test]
2756        async fn a_refusal_that_names_no_rule_still_carries_a_code() {
2757            let (app, service) = app();
2758            let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2759            // A duplicate the service reports as such: 409, coded.
2760            let (status, body) = call(&app, "PATCH", &format!("/widgets/{}", w.id), Some(r#"{"name":"taken"}"#)).await;
2761            assert_eq!(status, StatusCode::CONFLICT);
2762            assert_eq!(body["violations"][0]["code"], "conflict", "{body}");
2763            // A patch of the wrong type: 422 on the field.
2764            let (status, body) = call(&app, "PATCH", &format!("/widgets/{}", w.id), Some(r#"{"name":7}"#)).await;
2765            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2766            assert_eq!(body["violations"][0]["path"], "name");
2767        }
2768
2769        #[tokio::test]
2770        async fn a_server_fault_answers_generically_and_keeps_the_detail_out() {
2771            let (app, _) = app();
2772            let (status, body) = call(&app, "GET", &format!("/widgets/{BROKEN_ID}"), None).await;
2773            assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
2774            assert_eq!(body["error"], crate::http::INTERNAL_ERROR_MESSAGE);
2775            assert!(!body.to_string().contains("secret_schema"), "{body}");
2776        }
2777
2778        #[tokio::test]
2779        async fn a_duplicate_on_put_and_patch_is_a_409_like_on_create() {
2780            let (app, service) = app();
2781            let w = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap();
2782            let uri = format!("/widgets/{}", w.id);
2783            let (status, _) = call(&app, "PUT", &uri, Some(r#"{"name":"taken"}"#)).await;
2784            assert_eq!(status, StatusCode::CONFLICT);
2785            let (status, _) = call(&app, "PATCH", &uri, Some(r#"{"name":"taken"}"#)).await;
2786            assert_eq!(status, StatusCode::CONFLICT);
2787        }
2788
2789        #[tokio::test]
2790        async fn an_unknown_key_is_refused_on_create_put_and_bulk() {
2791            let (app, service) = app();
2792            let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","colour":"red"}"#)).await;
2793            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2794            assert_eq!(body["violations"][0]["code"], "unknown_field");
2795            assert_eq!(body["violations"][0]["path"], "colour");
2796            assert_eq!(service.count().await.unwrap(), 0, "nothing created");
2797
2798            // What the record's own response carries is tolerated.
2799            let (status, _) = call(&app, "POST", "/widgets", Some(r#"{"name":"a","id":"x","metadata":{}}"#)).await;
2800            assert_eq!(status, StatusCode::CREATED);
2801
2802            let w = service.list(1, 1, HashMap::new()).await.unwrap().0.remove(0);
2803            let (status, body) = call(&app, "PUT", &format!("/widgets/{}", w.id), Some(r#"{"name":"b","colour":"red"}"#)).await;
2804            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2805            assert_eq!(body["violations"][0]["path"], "colour");
2806
2807            let (status, body) = call(&app, "POST", "/widgets/bulk", Some(r#"[{"name":"x"},{"name":"y","colour":"red"}]"#)).await;
2808            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2809            assert_eq!(body["violations"][0]["path"], "1/colour");
2810
2811            let bulk = format!(r#"[{{"id":"{}","name":"z","colour":"red"}}]"#, w.id);
2812            let (status, body) = call(&app, "PUT", "/widgets/bulk", Some(&bulk)).await;
2813            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}");
2814            assert_eq!(body["violations"][0]["path"], "0/colour");
2815            assert_eq!(service.get_by_id(&w.id).await.unwrap().unwrap().name, "a", "nothing written");
2816        }
2817
2818        #[tokio::test]
2819        async fn a_body_that_cannot_be_read_answers_in_the_json_envelope() {
2820            let (app, _) = app();
2821            let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": "#)).await;
2822            assert_eq!(status, StatusCode::BAD_REQUEST);
2823            assert_eq!(body["success"], false);
2824            assert_eq!(body["violations"][0]["code"], "malformed_body");
2825
2826            let (status, body) = call(&app, "POST", "/widgets", Some(r#"{"name": 5}"#)).await;
2827            assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
2828            assert_eq!(body["violations"][0]["path"], "name");
2829        }
2830
2831        #[tokio::test]
2832        async fn the_generic_upsert_route_answers_501() {
2833            let (app, service) = app();
2834            let (status, _) = call(&app, "POST", "/widgets/upsert", Some(r#"{"name":"a"}"#)).await;
2835            assert_eq!(status, StatusCode::NOT_IMPLEMENTED);
2836            assert_eq!(service.count().await.unwrap(), 0);
2837        }
2838
2839        #[tokio::test]
2840        async fn the_trash_route_reads_the_filters() {
2841            let (app, service) = app();
2842            let a = service.create(CreateWidgetDto { name: "a".into() }).await.unwrap().id;
2843            let b = service.create(CreateWidgetDto { name: "b".into() }).await.unwrap().id;
2844            service.bulk_soft_delete(vec![a, b]).await.unwrap();
2845            let (status, body) = call(&app, "GET", "/widgets/trash?name=b", None).await;
2846            assert_eq!(status, StatusCode::OK);
2847            assert_eq!(body["data"].as_array().unwrap().len(), 1);
2848            assert_eq!(body["data"][0]["name"], "b");
2849        }
2850    }
2851}