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