//! The atomic data-plane write: a row mutation **and** the trigger fan-out it
//! produces, committed together or not at all.
//!
//! ## Why
//!
//! Every stateful service used to write the row, wait for it to commit, and
//! *then* ask the emitter to resolve matching triggers and insert outbox rows.
//! Those are separate transactions on separate connections, so the window
//! between them is a durability hole: if the outbox insert failed — or the
//! process died — the row was permanently in the store with its trigger having
//! never fired, and no retry could ever notice. The services could only log the
//! failure (`event_emit_failure = true`) and carry on.
//!
//! A `Writer` closes that hole. It takes a transaction, does the write, runs the
//! fan-out on the **same connection** (`outbox_event_emitter::emit_on(&mut *tx,
//! …)`), and commits. An emit failure now rolls the write back and surfaces as
//! an error to the script, which is the honest outcome: the caller learns the
//! write did not happen, rather than silently getting a store that disagrees
//! with its triggers.
//!
//! ## The pool-deadlock rule
//!
//! Everything inside a transaction MUST run on that transaction's connection.
//! A writer that held a `tx` and then reached for a *second* pooled connection
//! (e.g. by calling a repo method) could deadlock: the pool is sized to the
//! execution concurrency cap, so N executions each wanting 2 connections can
//! starve each other forever. That is why the fan-out and the quota reads below
//! all take `&mut *tx` and never touch a repo.
//!
//! ## Two implementations
//!
//! * `Postgres*Writer` — transactional, used by the host.
//! * `BestEffort*Writer` — writes through the repo trait, then emits
//!   best-effort. It is what the in-memory unit tests run against (no Postgres),
//!   and it preserves the pre-existing semantics exactly.
//!
//! Both sit behind the same trait, so the service body has a single code path;
//! which one is in play is a constructor choice, not a branch in the hot path.

use std::path::PathBuf;
use std::sync::Arc;

use async_trait::async_trait;
use picloud_shared::{
    DocId, DocsError, FileMeta, FileUpdate, FilesError, GroupDocsError, GroupFilesError, GroupId,
    GroupKvError, KvError, NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
};
use serde_json::Value;
use sqlx::{PgConnection, PgPool};
use uuid::Uuid;

use crate::docs_repo::{self, DocsRepo};
use crate::files_repo::{self, FileUpdated, FilesRepo, MetaFields, MetaPatch};
use crate::group_docs_repo::{self, GroupDocsRepo};
use crate::group_files_repo::{self, GroupFilesRepo, GroupFilesRepoError};
use crate::group_kv_repo::{self, GroupKvRepo};
use crate::kv_repo::{self, KvRepo};
use crate::outbox_event_emitter::{emit_on, emit_shared_on};
use crate::quota::{check_group_write, GroupUsage, GroupWriteQuota, QuotaOutcome};

/// Map a `sqlx` failure from the transaction plumbing itself (begin/commit).
fn tx_err(e: &sqlx::Error) -> KvError {
    KvError::Backend(format!("database error: {e}"))
}

/// A KV mutation's `ServiceEvent`. Shared by both writers so the event shape has
/// one home.
fn kv_event(
    op: &'static str,
    collection: &str,
    key: &str,
    payload: Option<Value>,
    old_payload: Option<Value>,
) -> ServiceEvent {
    ServiceEvent {
        source: "kv",
        op,
        collection: Some(collection.to_string()),
        key: Some(key.to_string()),
        payload,
        old_payload,
    }
}

/// The mutating half of the KV service: the row write **and** the trigger
/// fan-out that follows from it. Reads stay on `KvRepo`.
#[async_trait]
pub trait KvWriter: Send + Sync {
    /// Upsert + fan out. Returns the previous value — `None` means this was an
    /// insert rather than an update.
    async fn set(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        value: Value,
    ) -> Result<Option<Value>, KvError>;

    /// Compare-and-swap + fan out (only on a successful swap). Returns whether
    /// the swap happened.
    async fn set_if(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        expected: Option<Value>,
        new: Value,
    ) -> Result<bool, KvError>;

    /// Delete + fan out (only when the key was present). Returns the removed
    /// value.
    async fn delete(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
    ) -> Result<Option<Value>, KvError>;
}

// ----------------------------------------------------------------------------
// Transactional (Postgres)
// ----------------------------------------------------------------------------

pub struct PostgresKvWriter {
    pool: PgPool,
    /// Per-app key ceiling. Checked ONLY when a write adds a key — an update is
    /// net-zero rows and already bounded by the per-value cap, so it pays
    /// nothing. No lock: see the `quota` module for why.
    max_rows: u64,
}

impl PostgresKvWriter {
    #[must_use]
    pub fn new(pool: PgPool, max_rows: u64) -> Self {
        Self { pool, max_rows }
    }

    /// Refuse a key-adding write once the app is over its ceiling.
    ///
    /// Called AFTER the insert, so the count already includes the new key — hence
    /// `>` rather than `>=`. Checking afterwards lets us reuse the previous value
    /// the upsert already returns, instead of paying a second SELECT just to learn
    /// whether the write adds a row; the transaction rolls the insert back on
    /// refusal, so the outcome is identical. `kv::set` is the hottest write path in
    /// the system and does not need a redundant round-trip.
    async fn check_rows_after_insert(
        &self,
        conn: &mut PgConnection,
        app_id: picloud_shared::AppId,
    ) -> Result<(), KvError> {
        let count = kv_repo::count_rows_on(&mut *conn, app_id).await?;
        if count > self.max_rows {
            return Err(KvError::QuotaExceeded {
                limit: self.max_rows,
                actual: count,
            });
        }
        Ok(())
    }
}

#[async_trait]
impl KvWriter for PostgresKvWriter {
    async fn set(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        value: Value,
    ) -> Result<Option<Value>, KvError> {
        let mut tx = self.pool.begin().await.map_err(|e| tx_err(&e))?;
        let previous = kv_repo::set_on(&mut *tx, cx.app_id, collection, key, value.clone()).await?;
        // `previous.is_none()` means the upsert INSERTED, i.e. this write consumed
        // a row against the app's ceiling. An update is net-zero and pays nothing —
        // not even the COUNT.
        if previous.is_none() {
            self.check_rows_after_insert(&mut tx, cx.app_id).await?;
        }
        let op = if previous.is_some() {
            "update"
        } else {
            "insert"
        };
        let event = kv_event(op, collection, key, Some(value), previous.clone());
        // Same connection as the write above — the outbox rows commit with it.
        emit_on(&mut tx, cx, &event)
            .await
            .map_err(|e| KvError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| tx_err(&e))?;
        Ok(previous)
    }

    async fn set_if(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        expected: Option<Value>,
        new: Value,
    ) -> Result<bool, KvError> {
        // `insert` when the precondition was "absent", else `update`.
        let op = if expected.is_some() {
            "update"
        } else {
            "insert"
        };
        let mut tx = self.pool.begin().await.map_err(|e| tx_err(&e))?;
        // A swap only ADDS a key when the precondition was "absent" AND it actually
        // inserted. With a `Some` precondition it is an UPDATE (net-zero rows), and
        // against an absent key it cannot swap at all — either way, nothing to
        // charge. So the ceiling is checked only on an insert that really happened.
        let inserted = expected.is_none();
        let swapped =
            kv_repo::set_if_on(&mut *tx, cx.app_id, collection, key, expected, new.clone()).await?;
        if swapped && inserted {
            self.check_rows_after_insert(&mut tx, cx.app_id).await?;
        }
        if swapped {
            let event = kv_event(op, collection, key, Some(new), None);
            emit_on(&mut tx, cx, &event)
                .await
                .map_err(|e| KvError::Backend(format!("event emit: {e}")))?;
        }
        tx.commit().await.map_err(|e| tx_err(&e))?;
        Ok(swapped)
    }

    async fn delete(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
    ) -> Result<Option<Value>, KvError> {
        let mut tx = self.pool.begin().await.map_err(|e| tx_err(&e))?;
        let previous = kv_repo::delete_on(&mut *tx, cx.app_id, collection, key).await?;
        if previous.is_some() {
            let event = kv_event("delete", collection, key, None, previous.clone());
            emit_on(&mut tx, cx, &event)
                .await
                .map_err(|e| KvError::Backend(format!("event emit: {e}")))?;
        }
        tx.commit().await.map_err(|e| tx_err(&e))?;
        Ok(previous)
    }
}

// ----------------------------------------------------------------------------
// Best-effort (repo trait + emitter trait)
// ----------------------------------------------------------------------------

/// A `files_event` that failed to serialize. Never silently dropped — that is the
/// exact class of loss this branch exists to remove.
fn log_event_build_failure(op: &str, e: &str) {
    tracing::error!(
        error = %e, source = "files", op, event_emit_failure = true,
        "could not build the file event — the write committed but its triggers will not fire"
    );
}

/// Emit, downgrading a failure to an error-level log. The write has already
/// committed by this point, so there is nothing useful to return to the caller —
/// which is exactly the durability hole `PostgresKvWriter` exists to close.
/// `event_emit_failure = true` is there to be grepped/alerted on.
async fn best_effort_emit(events: &dyn ServiceEventEmitter, cx: &SdkCallCx, event: ServiceEvent) {
    let (source, op) = (event.source, event.op);
    if let Err(e) = events.emit(cx, event).await {
        tracing::error!(
            error = %e, source, op, event_emit_failure = true,
            "event emit failed — the write committed but its triggers will not fire"
        );
    }
}

pub struct BestEffortKvWriter {
    repo: Arc<dyn KvRepo>,
    events: Arc<dyn ServiceEventEmitter>,
}

impl BestEffortKvWriter {
    #[must_use]
    pub fn new(repo: Arc<dyn KvRepo>, events: Arc<dyn ServiceEventEmitter>) -> Self {
        Self { repo, events }
    }
}

#[async_trait]
impl KvWriter for BestEffortKvWriter {
    async fn set(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        value: Value,
    ) -> Result<Option<Value>, KvError> {
        let previous = self
            .repo
            .set(cx.app_id, collection, key, value.clone())
            .await?;
        let op = if previous.is_some() {
            "update"
        } else {
            "insert"
        };
        best_effort_emit(
            &*self.events,
            cx,
            kv_event(op, collection, key, Some(value), previous.clone()),
        )
        .await;
        Ok(previous)
    }

    async fn set_if(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
        expected: Option<Value>,
        new: Value,
    ) -> Result<bool, KvError> {
        let op = if expected.is_some() {
            "update"
        } else {
            "insert"
        };
        let swapped = self
            .repo
            .set_if(cx.app_id, collection, key, expected, new.clone())
            .await?;
        if swapped {
            best_effort_emit(
                &*self.events,
                cx,
                kv_event(op, collection, key, Some(new), None),
            )
            .await;
        }
        Ok(swapped)
    }

    async fn delete(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        key: &str,
    ) -> Result<Option<Value>, KvError> {
        let previous = self.repo.delete(cx.app_id, collection, key).await?;
        if previous.is_some() {
            best_effort_emit(
                &*self.events,
                cx,
                kv_event("delete", collection, key, None, previous.clone()),
            )
            .await;
        }
        Ok(previous)
    }
}

// ----------------------------------------------------------------------------
// §11.6 group (shared-collection) KV
//
// Adds a second reason to hold a transaction: the QUOTA. The service used to
// read the group's usage on one pooled connection and write on another, so two
// concurrent writers both observed the pre-write total, both passed the check,
// and together pushed the group past its ceiling (audit #8). Reading usage
// inside the writing transaction is necessary but not sufficient — under READ
// COMMITTED each transaction's aggregate still sees a snapshot without the
// other's uncommitted row. So the transaction ALSO takes a per-group advisory
// lock, which serializes check-then-write for that group and makes the ceiling
// an actual bound rather than a hint.
// ----------------------------------------------------------------------------

/// Where a group-KV mutation lands.
#[derive(Clone, Copy)]
pub struct GroupKvTarget<'a> {
    pub group_id: GroupId,
    pub collection: &'a str,
    pub key: &'a str,
}

/// The mutating half of the group-KV service: the quota decision, the row write,
/// and the shared-trigger fan-out — which must all agree, so they all commit
/// together.
#[async_trait]
pub trait GroupKvWriter: Send + Sync {
    async fn set(
        &self,
        cx: &SdkCallCx,
        target: GroupKvTarget<'_>,
        value: Value,
        quota: GroupWriteQuota,
    ) -> Result<(), GroupKvError>;

    async fn set_if(
        &self,
        cx: &SdkCallCx,
        target: GroupKvTarget<'_>,
        expected: Option<Value>,
        new: Value,
        quota: GroupWriteQuota,
    ) -> Result<bool, GroupKvError>;

    async fn delete(&self, cx: &SdkCallCx, target: GroupKvTarget<'_>)
        -> Result<bool, GroupKvError>;
}

fn group_tx_err(e: &sqlx::Error) -> GroupKvError {
    GroupKvError::Backend(format!("database error: {e}"))
}

/// Turn the shared quota decision into the service's error type.
fn quota_gate(outcome: QuotaOutcome) -> Result<(), GroupKvError> {
    match outcome {
        QuotaOutcome::Allowed => Ok(()),
        QuotaOutcome::RowsExceeded { limit, actual } => Err(GroupKvError::QuotaExceeded {
            limit: usize::try_from(limit).unwrap_or(usize::MAX),
            actual: usize::try_from(actual).unwrap_or(usize::MAX),
        }),
        QuotaOutcome::BytesExceeded { limit, actual } => {
            Err(GroupKvError::TotalBytesQuotaExceeded { limit, actual })
        }
    }
}

/// Stable `pg_advisory_xact_lock` key for a group's shared-collection quota
/// accounting. Held for the life of the writing transaction, so concurrent
/// writers to the same group queue up behind each other for the
/// check-then-write and one of them sees the other's row.
///
/// The key is namespaced per KIND (`kv` / `docs` / …): a KV write and a docs
/// write draw on separate ceilings, so serializing them against each other would
/// be pure contention for no safety.
fn quota_lock_key(owner: uuid::Uuid, scope: &str, kind: &str) -> i64 {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut h = DefaultHasher::new();
    scope.hash(&mut h);
    kind.hash(&mut h);
    owner.hash(&mut h);
    // Postgres advisory locks key on bigint; reinterpret the u64 as i64.
    #[allow(clippy::cast_possible_wrap)]
    let key = h.finish() as i64;
    key
}

async fn lock_group_quota(
    conn: &mut PgConnection,
    group_id: GroupId,
    kind: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query("SELECT pg_advisory_xact_lock($1)")
        .bind(quota_lock_key(group_id.into_inner(), "group_quota", kind))
        .execute(conn)
        .await?;
    Ok(())
}

/// Per-app FILES only — see the `quota` module for why the per-app KV/docs
/// ceilings deliberately do NOT lock.
async fn lock_app_quota(
    conn: &mut PgConnection,
    app_id: picloud_shared::AppId,
    kind: &str,
) -> Result<(), sqlx::Error> {
    sqlx::query("SELECT pg_advisory_xact_lock($1)")
        .bind(quota_lock_key(app_id.into_inner(), "app_quota", kind))
        .execute(conn)
        .await?;
    Ok(())
}

/// Group-KV usage, read on the WRITING transaction's connection.
struct TxGroupKvUsage<'a> {
    conn: &'a mut PgConnection,
    group_id: GroupId,
    collection: &'a str,
    key: &'a str,
    new_value: &'a Value,
}

#[async_trait]
impl GroupUsage for TxGroupKvUsage<'_> {
    async fn count_rows(&mut self) -> Result<u64, String> {
        group_kv_repo::count_rows_on(&mut *self.conn, self.group_id)
            .await
            .map_err(|e| e.to_string())
    }

    async fn projected_total_bytes(&mut self) -> Result<u64, String> {
        group_kv_repo::projected_total_bytes_on(
            &mut *self.conn,
            self.group_id,
            self.collection,
            self.key,
            self.new_value,
        )
        .await
        .map_err(|e| e.to_string())
    }
}

pub struct PostgresGroupKvWriter {
    pool: PgPool,
}

impl PostgresGroupKvWriter {
    #[must_use]
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl GroupKvWriter for PostgresGroupKvWriter {
    async fn set(
        &self,
        cx: &SdkCallCx,
        t: GroupKvTarget<'_>,
        value: Value,
        quota: GroupWriteQuota,
    ) -> Result<(), GroupKvError> {
        let mut tx = self.pool.begin().await.map_err(|e| group_tx_err(&e))?;
        lock_group_quota(&mut tx, t.group_id, "kv")
            .await
            .map_err(|e| group_tx_err(&e))?;

        let existed = group_kv_repo::get_on(&mut *tx, t.group_id, t.collection, t.key)
            .await?
            .is_some();
        let mut usage = TxGroupKvUsage {
            conn: &mut tx,
            group_id: t.group_id,
            collection: t.collection,
            key: t.key,
            new_value: &value,
        };
        let outcome = check_group_write(quota, !existed, &mut usage)
            .await
            .map_err(GroupKvError::Backend)?;
        quota_gate(outcome)?;

        group_kv_repo::set_on(&mut *tx, t.group_id, t.collection, t.key, value.clone()).await?;
        let op = if existed { "update" } else { "insert" };
        emit_shared_on(
            &mut tx,
            cx,
            t.group_id,
            &kv_event(op, t.collection, t.key, Some(value), None),
        )
        .await
        .map_err(|e| GroupKvError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| group_tx_err(&e))?;
        Ok(())
    }

    async fn set_if(
        &self,
        cx: &SdkCallCx,
        t: GroupKvTarget<'_>,
        expected: Option<Value>,
        new: Value,
        quota: GroupWriteQuota,
    ) -> Result<bool, GroupKvError> {
        let mut tx = self.pool.begin().await.map_err(|e| group_tx_err(&e))?;
        lock_group_quota(&mut tx, t.group_id, "kv")
            .await
            .map_err(|e| group_tx_err(&e))?;

        let existed = group_kv_repo::get_on(&mut *tx, t.group_id, t.collection, t.key)
            .await?
            .is_some();
        // A swap only ADDS a row when the precondition was "absent" and the key
        // really is absent. With a `Some` precondition against an absent key the
        // swap cannot happen at all, so it consumes nothing.
        let adds_row = expected.is_none() && !existed;
        let mut usage = TxGroupKvUsage {
            conn: &mut tx,
            group_id: t.group_id,
            collection: t.collection,
            key: t.key,
            new_value: &new,
        };
        let outcome = check_group_write(quota, adds_row, &mut usage)
            .await
            .map_err(GroupKvError::Backend)?;
        quota_gate(outcome)?;

        let op = if expected.is_some() {
            "update"
        } else {
            "insert"
        };
        let swapped = group_kv_repo::set_if_on(
            &mut *tx,
            t.group_id,
            t.collection,
            t.key,
            expected,
            new.clone(),
        )
        .await?;
        if swapped {
            emit_shared_on(
                &mut tx,
                cx,
                t.group_id,
                &kv_event(op, t.collection, t.key, Some(new), None),
            )
            .await
            .map_err(|e| GroupKvError::Backend(format!("event emit: {e}")))?;
        }
        tx.commit().await.map_err(|e| group_tx_err(&e))?;
        Ok(swapped)
    }

    async fn delete(&self, cx: &SdkCallCx, t: GroupKvTarget<'_>) -> Result<bool, GroupKvError> {
        // No quota to check (a delete only frees space) and so no lock to take.
        let mut tx = self.pool.begin().await.map_err(|e| group_tx_err(&e))?;
        let deleted = group_kv_repo::delete_on(&mut *tx, t.group_id, t.collection, t.key)
            .await?
            .is_some();
        if deleted {
            emit_shared_on(
                &mut tx,
                cx,
                t.group_id,
                &kv_event("delete", t.collection, t.key, None, None),
            )
            .await
            .map_err(|e| GroupKvError::Backend(format!("event emit: {e}")))?;
        }
        tx.commit().await.map_err(|e| group_tx_err(&e))?;
        Ok(deleted)
    }
}

// ---- Best-effort group-KV writer (in-memory tests) --------------------------

/// Group-KV usage read through the repo trait — one pooled call each, i.e. NOT
/// serialized against a concurrent writer. Only the in-memory unit tests run
/// on this; the host uses `PostgresGroupKvWriter`.
struct RepoGroupKvUsage<'a> {
    repo: &'a dyn GroupKvRepo,
    group_id: GroupId,
    collection: &'a str,
    key: &'a str,
    new_value: &'a Value,
}

#[async_trait]
impl GroupUsage for RepoGroupKvUsage<'_> {
    async fn count_rows(&mut self) -> Result<u64, String> {
        self.repo
            .count_rows(self.group_id)
            .await
            .map_err(|e| e.to_string())
    }

    async fn projected_total_bytes(&mut self) -> Result<u64, String> {
        self.repo
            .projected_total_bytes(self.group_id, self.collection, self.key, self.new_value)
            .await
            .map_err(|e| e.to_string())
    }
}

pub struct BestEffortGroupKvWriter {
    repo: Arc<dyn GroupKvRepo>,
    events: Arc<dyn ServiceEventEmitter>,
}

impl BestEffortGroupKvWriter {
    #[must_use]
    pub fn new(repo: Arc<dyn GroupKvRepo>, events: Arc<dyn ServiceEventEmitter>) -> Self {
        Self { repo, events }
    }

    async fn emit(&self, cx: &SdkCallCx, group_id: GroupId, event: ServiceEvent) {
        let (source, op) = (event.source, event.op);
        if let Err(e) = self.events.emit_shared(cx, group_id, event).await {
            tracing::error!(
                error = %e, source, op, event_emit_failure = true,
                "shared event emit failed — the write committed but its triggers will not fire"
            );
        }
    }

    async fn check(
        &self,
        t: GroupKvTarget<'_>,
        adds_row: bool,
        new_value: &Value,
        quota: GroupWriteQuota,
    ) -> Result<(), GroupKvError> {
        let mut usage = RepoGroupKvUsage {
            repo: &*self.repo,
            group_id: t.group_id,
            collection: t.collection,
            key: t.key,
            new_value,
        };
        let outcome = check_group_write(quota, adds_row, &mut usage)
            .await
            .map_err(GroupKvError::Backend)?;
        quota_gate(outcome)
    }
}

#[async_trait]
impl GroupKvWriter for BestEffortGroupKvWriter {
    async fn set(
        &self,
        cx: &SdkCallCx,
        t: GroupKvTarget<'_>,
        value: Value,
        quota: GroupWriteQuota,
    ) -> Result<(), GroupKvError> {
        let existed = self
            .repo
            .get(t.group_id, t.collection, t.key)
            .await?
            .is_some();
        self.check(t, !existed, &value, quota).await?;
        self.repo
            .set(t.group_id, t.collection, t.key, value.clone())
            .await?;
        let op = if existed { "update" } else { "insert" };
        self.emit(
            cx,
            t.group_id,
            kv_event(op, t.collection, t.key, Some(value), None),
        )
        .await;
        Ok(())
    }

    async fn set_if(
        &self,
        cx: &SdkCallCx,
        t: GroupKvTarget<'_>,
        expected: Option<Value>,
        new: Value,
        quota: GroupWriteQuota,
    ) -> Result<bool, GroupKvError> {
        let existed = self
            .repo
            .get(t.group_id, t.collection, t.key)
            .await?
            .is_some();
        let adds_row = expected.is_none() && !existed;
        self.check(t, adds_row, &new, quota).await?;
        let op = if expected.is_some() {
            "update"
        } else {
            "insert"
        };
        let swapped = self
            .repo
            .set_if(t.group_id, t.collection, t.key, expected, new.clone())
            .await?;
        if swapped {
            self.emit(
                cx,
                t.group_id,
                kv_event(op, t.collection, t.key, Some(new), None),
            )
            .await;
        }
        Ok(swapped)
    }

    async fn delete(&self, cx: &SdkCallCx, t: GroupKvTarget<'_>) -> Result<bool, GroupKvError> {
        let deleted = self
            .repo
            .delete(t.group_id, t.collection, t.key)
            .await?
            .is_some();
        if deleted {
            self.emit(
                cx,
                t.group_id,
                kv_event("delete", t.collection, t.key, None, None),
            )
            .await;
        }
        Ok(deleted)
    }
}

// ----------------------------------------------------------------------------
// Docs (per-app)
// ----------------------------------------------------------------------------

/// A docs mutation's `ServiceEvent`. The emitter turns `old_payload` into the
/// `prev_data` change-data-capture surface handlers see.
fn docs_event(
    op: &'static str,
    collection: &str,
    id: DocId,
    payload: Option<Value>,
    old_payload: Option<Value>,
) -> ServiceEvent {
    ServiceEvent {
        source: "docs",
        op,
        collection: Some(collection.to_string()),
        key: Some(id.to_string()),
        payload,
        old_payload,
    }
}

fn docs_tx_err(e: &sqlx::Error) -> DocsError {
    DocsError::Backend(format!("database error: {e}"))
}

/// The mutating half of the docs service: the write AND the trigger fan-out it
/// produces. Reads stay on `DocsRepo`.
#[async_trait]
pub trait DocsWriter: Send + Sync {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        data: Value,
    ) -> Result<DocId, DocsError>;

    /// `Ok(false)` when the doc does not exist (the caller maps that to
    /// `NotFound`); no event is emitted in that case.
    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: DocId,
        data: Value,
    ) -> Result<bool, DocsError>;

    /// `Ok(false)` when the doc was already absent.
    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError>;
}

pub struct PostgresDocsWriter {
    pool: PgPool,
    /// Per-app doc ceiling. Checked on `create` only — an update is net-zero
    /// rows and bounded by the per-doc value cap. No lock: see `quota`.
    max_rows: u64,
}

impl PostgresDocsWriter {
    #[must_use]
    pub fn new(pool: PgPool, max_rows: u64) -> Self {
        Self { pool, max_rows }
    }
}

#[async_trait]
impl DocsWriter for PostgresDocsWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        data: Value,
    ) -> Result<DocId, DocsError> {
        let mut tx = self.pool.begin().await.map_err(|e| docs_tx_err(&e))?;
        let count = docs_repo::count_rows_on(&mut *tx, cx.app_id).await?;
        if count >= self.max_rows {
            return Err(DocsError::QuotaExceeded {
                limit: self.max_rows,
                actual: count,
            });
        }
        let row = docs_repo::create_on(&mut *tx, cx.app_id, collection, data.clone()).await?;
        emit_on(
            &mut tx,
            cx,
            &docs_event("create", collection, row.id, Some(data), None),
        )
        .await
        .map_err(|e| DocsError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| docs_tx_err(&e))?;
        Ok(row.id)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: DocId,
        data: Value,
    ) -> Result<bool, DocsError> {
        let mut tx = self.pool.begin().await.map_err(|e| docs_tx_err(&e))?;
        let previous =
            docs_repo::update_on(&mut *tx, cx.app_id, collection, id, data.clone()).await?;
        let Some(prev) = previous else {
            return Ok(false);
        };
        emit_on(
            &mut tx,
            cx,
            &docs_event("update", collection, id, Some(data), Some(prev)),
        )
        .await
        .map_err(|e| DocsError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| docs_tx_err(&e))?;
        Ok(true)
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
        let mut tx = self.pool.begin().await.map_err(|e| docs_tx_err(&e))?;
        let previous = docs_repo::delete_on(&mut *tx, cx.app_id, collection, id).await?;
        let Some(prev) = previous else {
            return Ok(false);
        };
        emit_on(
            &mut tx,
            cx,
            &docs_event("delete", collection, id, None, Some(prev)),
        )
        .await
        .map_err(|e| DocsError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| docs_tx_err(&e))?;
        Ok(true)
    }
}

pub struct BestEffortDocsWriter {
    repo: Arc<dyn DocsRepo>,
    events: Arc<dyn ServiceEventEmitter>,
}

impl BestEffortDocsWriter {
    #[must_use]
    pub fn new(repo: Arc<dyn DocsRepo>, events: Arc<dyn ServiceEventEmitter>) -> Self {
        Self { repo, events }
    }
}

#[async_trait]
impl DocsWriter for BestEffortDocsWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        data: Value,
    ) -> Result<DocId, DocsError> {
        let row = self
            .repo
            .create(cx.app_id, collection, data.clone())
            .await?;
        best_effort_emit(
            &*self.events,
            cx,
            docs_event("create", collection, row.id, Some(data), None),
        )
        .await;
        Ok(row.id)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: DocId,
        data: Value,
    ) -> Result<bool, DocsError> {
        let Some(prev) = self
            .repo
            .update(cx.app_id, collection, id, data.clone())
            .await?
        else {
            return Ok(false);
        };
        best_effort_emit(
            &*self.events,
            cx,
            docs_event("update", collection, id, Some(data), Some(prev)),
        )
        .await;
        Ok(true)
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
        let Some(prev) = self.repo.delete(cx.app_id, collection, id).await? else {
            return Ok(false);
        };
        best_effort_emit(
            &*self.events,
            cx,
            docs_event("delete", collection, id, None, Some(prev)),
        )
        .await;
        Ok(true)
    }
}

// ----------------------------------------------------------------------------
// §11.6 group (shared-collection) docs
//
// Same shape as group KV: the quota reads, the write, and the shared-trigger
// fan-out on one connection under a per-group advisory lock.
// ----------------------------------------------------------------------------

/// Where a group-docs mutation lands. `id` is `None` for a create (the row's id
/// is minted by the write).
#[derive(Clone, Copy)]
pub struct GroupDocsTarget<'a> {
    pub group_id: GroupId,
    pub collection: &'a str,
}

#[async_trait]
pub trait GroupDocsWriter: Send + Sync {
    async fn create(
        &self,
        cx: &SdkCallCx,
        target: GroupDocsTarget<'_>,
        data: Value,
        quota: GroupWriteQuota,
    ) -> Result<DocId, GroupDocsError>;

    /// `Ok(false)` when the doc does not exist.
    async fn update(
        &self,
        cx: &SdkCallCx,
        target: GroupDocsTarget<'_>,
        id: DocId,
        data: Value,
        quota: GroupWriteQuota,
    ) -> Result<bool, GroupDocsError>;

    async fn delete(
        &self,
        cx: &SdkCallCx,
        target: GroupDocsTarget<'_>,
        id: DocId,
    ) -> Result<bool, GroupDocsError>;
}

fn group_docs_tx_err(e: &sqlx::Error) -> GroupDocsError {
    GroupDocsError::Backend(format!("database error: {e}"))
}

fn docs_quota_gate(outcome: QuotaOutcome) -> Result<(), GroupDocsError> {
    match outcome {
        QuotaOutcome::Allowed => Ok(()),
        QuotaOutcome::RowsExceeded { limit, actual } => Err(GroupDocsError::QuotaExceeded {
            limit: usize::try_from(limit).unwrap_or(usize::MAX),
            actual: usize::try_from(actual).unwrap_or(usize::MAX),
        }),
        QuotaOutcome::BytesExceeded { limit, actual } => {
            Err(GroupDocsError::TotalBytesQuotaExceeded { limit, actual })
        }
    }
}

/// Group-docs usage, read on the WRITING transaction's connection.
struct TxGroupDocsUsage<'a> {
    conn: &'a mut PgConnection,
    group_id: GroupId,
    /// `Some(id)` when this write replaces an existing doc, so its bytes are
    /// subtracted from the projection; `None` on create.
    replacing: Option<DocId>,
    new_data: &'a Value,
}

#[async_trait]
impl GroupUsage for TxGroupDocsUsage<'_> {
    async fn count_rows(&mut self) -> Result<u64, String> {
        group_docs_repo::count_rows_on(&mut *self.conn, self.group_id)
            .await
            .map_err(|e| e.to_string())
    }

    async fn projected_total_bytes(&mut self) -> Result<u64, String> {
        group_docs_repo::projected_total_bytes_on(
            &mut *self.conn,
            self.group_id,
            self.replacing,
            self.new_data,
        )
        .await
        .map_err(|e| e.to_string())
    }
}

pub struct PostgresGroupDocsWriter {
    pool: PgPool,
}

impl PostgresGroupDocsWriter {
    #[must_use]
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl GroupDocsWriter for PostgresGroupDocsWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        t: GroupDocsTarget<'_>,
        data: Value,
        quota: GroupWriteQuota,
    ) -> Result<DocId, GroupDocsError> {
        let mut tx = self.pool.begin().await.map_err(|e| group_docs_tx_err(&e))?;
        lock_group_quota(&mut tx, t.group_id, "docs")
            .await
            .map_err(|e| group_docs_tx_err(&e))?;

        let mut usage = TxGroupDocsUsage {
            conn: &mut tx,
            group_id: t.group_id,
            replacing: None,
            new_data: &data,
        };
        let outcome = check_group_write(quota, true, &mut usage)
            .await
            .map_err(GroupDocsError::Backend)?;
        docs_quota_gate(outcome)?;

        let created =
            group_docs_repo::create_on(&mut *tx, t.group_id, t.collection, data.clone()).await?;
        emit_shared_on(
            &mut tx,
            cx,
            t.group_id,
            &docs_event("create", t.collection, created.id, Some(data), None),
        )
        .await
        .map_err(|e| GroupDocsError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| group_docs_tx_err(&e))?;
        Ok(created.id)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        t: GroupDocsTarget<'_>,
        id: DocId,
        data: Value,
        quota: GroupWriteQuota,
    ) -> Result<bool, GroupDocsError> {
        let mut tx = self.pool.begin().await.map_err(|e| group_docs_tx_err(&e))?;
        lock_group_quota(&mut tx, t.group_id, "docs")
            .await
            .map_err(|e| group_docs_tx_err(&e))?;

        // An update replaces a row rather than adding one, so it is exempt from
        // the row ceiling; the byte projection subtracts the replaced doc in SQL.
        // A missing doc subtracts 0 and the write below no-ops to `None`.
        let mut usage = TxGroupDocsUsage {
            conn: &mut tx,
            group_id: t.group_id,
            replacing: Some(id),
            new_data: &data,
        };
        let outcome = check_group_write(quota, false, &mut usage)
            .await
            .map_err(GroupDocsError::Backend)?;
        docs_quota_gate(outcome)?;

        let previous =
            group_docs_repo::update_on(&mut *tx, t.group_id, t.collection, id, data.clone())
                .await?;
        let Some(prev) = previous else {
            return Ok(false);
        };
        emit_shared_on(
            &mut tx,
            cx,
            t.group_id,
            &docs_event("update", t.collection, id, Some(data), Some(prev)),
        )
        .await
        .map_err(|e| GroupDocsError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| group_docs_tx_err(&e))?;
        Ok(true)
    }

    async fn delete(
        &self,
        cx: &SdkCallCx,
        t: GroupDocsTarget<'_>,
        id: DocId,
    ) -> Result<bool, GroupDocsError> {
        // No quota to check (a delete only frees space) and so no lock to take.
        let mut tx = self.pool.begin().await.map_err(|e| group_docs_tx_err(&e))?;
        let previous = group_docs_repo::delete_on(&mut *tx, t.group_id, t.collection, id).await?;
        let Some(prev) = previous else {
            return Ok(false);
        };
        emit_shared_on(
            &mut tx,
            cx,
            t.group_id,
            &docs_event("delete", t.collection, id, None, Some(prev)),
        )
        .await
        .map_err(|e| GroupDocsError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| group_docs_tx_err(&e))?;
        Ok(true)
    }
}

// ---- Best-effort group-docs writer (in-memory tests) ------------------------

struct RepoGroupDocsUsage<'a> {
    repo: &'a dyn GroupDocsRepo,
    group_id: GroupId,
    replacing: Option<DocId>,
    new_data: &'a Value,
}

#[async_trait]
impl GroupUsage for RepoGroupDocsUsage<'_> {
    async fn count_rows(&mut self) -> Result<u64, String> {
        self.repo
            .count_rows(self.group_id)
            .await
            .map_err(|e| e.to_string())
    }

    async fn projected_total_bytes(&mut self) -> Result<u64, String> {
        self.repo
            .projected_total_bytes(self.group_id, self.replacing, self.new_data)
            .await
            .map_err(|e| e.to_string())
    }
}

pub struct BestEffortGroupDocsWriter {
    repo: Arc<dyn GroupDocsRepo>,
    events: Arc<dyn ServiceEventEmitter>,
}

impl BestEffortGroupDocsWriter {
    #[must_use]
    pub fn new(repo: Arc<dyn GroupDocsRepo>, events: Arc<dyn ServiceEventEmitter>) -> Self {
        Self { repo, events }
    }

    async fn emit(&self, cx: &SdkCallCx, group_id: GroupId, event: ServiceEvent) {
        let (source, op) = (event.source, event.op);
        if let Err(e) = self.events.emit_shared(cx, group_id, event).await {
            tracing::error!(
                error = %e, source, op, event_emit_failure = true,
                "shared event emit failed — the write committed but its triggers will not fire"
            );
        }
    }

    async fn check(
        &self,
        group_id: GroupId,
        adds_row: bool,
        replacing: Option<DocId>,
        new_data: &Value,
        quota: GroupWriteQuota,
    ) -> Result<(), GroupDocsError> {
        let mut usage = RepoGroupDocsUsage {
            repo: &*self.repo,
            group_id,
            replacing,
            new_data,
        };
        let outcome = check_group_write(quota, adds_row, &mut usage)
            .await
            .map_err(GroupDocsError::Backend)?;
        docs_quota_gate(outcome)
    }
}

#[async_trait]
impl GroupDocsWriter for BestEffortGroupDocsWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        t: GroupDocsTarget<'_>,
        data: Value,
        quota: GroupWriteQuota,
    ) -> Result<DocId, GroupDocsError> {
        self.check(t.group_id, true, None, &data, quota).await?;
        let created = self
            .repo
            .create(t.group_id, t.collection, data.clone())
            .await?;
        self.emit(
            cx,
            t.group_id,
            docs_event("create", t.collection, created.id, Some(data), None),
        )
        .await;
        Ok(created.id)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        t: GroupDocsTarget<'_>,
        id: DocId,
        data: Value,
        quota: GroupWriteQuota,
    ) -> Result<bool, GroupDocsError> {
        self.check(t.group_id, false, Some(id), &data, quota)
            .await?;
        let Some(prev) = self
            .repo
            .update(t.group_id, t.collection, id, data.clone())
            .await?
        else {
            return Ok(false);
        };
        self.emit(
            cx,
            t.group_id,
            docs_event("update", t.collection, id, Some(data), Some(prev)),
        )
        .await;
        Ok(true)
    }

    async fn delete(
        &self,
        cx: &SdkCallCx,
        t: GroupDocsTarget<'_>,
        id: DocId,
    ) -> Result<bool, GroupDocsError> {
        let Some(prev) = self.repo.delete(t.group_id, t.collection, id).await? else {
            return Ok(false);
        };
        self.emit(
            cx,
            t.group_id,
            docs_event("delete", t.collection, id, None, Some(prev)),
        )
        .await;
        Ok(true)
    }
}

// ----------------------------------------------------------------------------
// Files (per-app and §11.6 group)
//
// Files differ from KV/docs in one way that matters: the BYTES live on disk and
// cannot join a transaction. What can — and now does — is the metadata row and
// the trigger fan-out, so a committed file row always has its event enqueued.
//
// Ordering rules, which are the whole game here:
//   * create/update — write the blob FIRST, then commit metadata + fan-out. If
//     the transaction rolls back, unlink the blob. (A crash at exactly that
//     point still orphans it; that hazard predates this change, and the orphan
//     is inert — nothing references it.)
//   * delete — commit the metadata removal + fan-out FIRST, then unlink. The
//     reverse order would destroy the bytes of a row that a rollback keeps.
// ----------------------------------------------------------------------------

/// A files mutation's `ServiceEvent`. The payload is the `FileMeta` JSON —
/// never the blob bytes.
fn files_event(
    op: &'static str,
    collection: &str,
    meta: &FileMeta,
    prev: Option<&FileMeta>,
) -> Result<ServiceEvent, String> {
    Ok(ServiceEvent {
        source: "files",
        op,
        collection: Some(collection.to_string()),
        key: Some(meta.id.to_string()),
        payload: Some(serde_json::to_value(meta).map_err(|e| format!("serialize file meta: {e}"))?),
        old_payload: match prev {
            Some(p) => {
                Some(serde_json::to_value(p).map_err(|e| format!("serialize file meta: {e}"))?)
            }
            None => None,
        },
    })
}

fn files_tx_err(e: &sqlx::Error) -> FilesError {
    FilesError::Backend(format!("database error: {e}"))
}

/// The mutating half of the files service: the blob write, the metadata row, and
/// the trigger fan-out. Reads stay on `FilesRepo`.
#[async_trait]
pub trait FilesWriter: Send + Sync {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        new: NewFile,
    ) -> Result<Uuid, FilesError>;

    /// `Ok(false)` when the file does not exist.
    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
    ) -> Result<bool, FilesError>;

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: Uuid) -> Result<bool, FilesError>;
}

pub struct PostgresFilesWriter {
    pool: PgPool,
    root: PathBuf,
    /// Per-app stored-blob ceiling. Unlike KV/docs this one DOES take a lock and
    /// DOES sum bytes: a blob may be 100 MB, so racing uploads could overshoot by
    /// gigabytes of real disk, and a file write is heavy enough that the lock and
    /// the SUM are lost in the noise.
    max_total_bytes: u64,
}

impl PostgresFilesWriter {
    #[must_use]
    pub fn new(pool: PgPool, root: PathBuf, max_total_bytes: u64) -> Self {
        Self {
            pool,
            root,
            max_total_bytes,
        }
    }

    /// The projected total (the replaced blob's bytes subtracted) must fit under
    /// the ceiling. Runs on the writing transaction's connection, under the
    /// per-app advisory lock the caller has taken.
    async fn check_bytes(
        &self,
        conn: &mut PgConnection,
        app_id: picloud_shared::AppId,
        replacing: Option<Uuid>,
        incoming: i64,
    ) -> Result<(), FilesError> {
        let projected =
            files_repo::projected_total_bytes_on(&mut *conn, app_id, replacing, incoming).await?;
        if projected > self.max_total_bytes {
            return Err(FilesError::QuotaExceeded {
                limit: self.max_total_bytes,
                actual: projected,
            });
        }
        Ok(())
    }
}

#[async_trait]
impl FilesWriter for PostgresFilesWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        new: NewFile,
    ) -> Result<Uuid, FilesError> {
        let owner = files_repo::app_owner_dir(cx.app_id);
        let id = Uuid::new_v4();
        let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);

        let committed = async {
            let mut tx = self.pool.begin().await.map_err(|e| files_tx_err(&e))?;
            lock_app_quota(&mut tx, cx.app_id, "files")
                .await
                .map_err(|e| files_tx_err(&e))?;
            // Quota FIRST, before a single byte reaches the disk. Writing the blob
            // and only then refusing would let an over-quota caller drive unbounded
            // write+unlink churn — the ceiling would bound storage but not I/O.
            self.check_bytes(&mut tx, cx.app_id, None, size).await?;
            // The bytes cannot join the transaction, so they go down inside it,
            // under the lock: a rollback below unlinks them, and nothing else can
            // reference them in the meantime.
            let checksum =
                files_repo::write_atomic_at(&self.root, &owner, collection, id, &new.data)?;
            let meta = files_repo::insert_meta_on(
                &mut *tx,
                cx.app_id,
                collection,
                id,
                MetaFields {
                    name: &new.name,
                    content_type: &new.content_type,
                    size,
                    checksum: &checksum,
                },
            )
            .await?;
            let event =
                files_event("create", collection, &meta, None).map_err(FilesError::Backend)?;
            emit_on(&mut tx, cx, &event)
                .await
                .map_err(|e| FilesError::Backend(format!("event emit: {e}")))?;
            tx.commit().await.map_err(|e| files_tx_err(&e))?;
            Ok::<_, FilesError>(meta.id)
        }
        .await;

        match committed {
            Ok(id) => Ok(id),
            Err(e) => {
                // The metadata never landed, so nothing references these bytes.
                files_repo::unlink_blob(&self.root, &owner, collection, id);
                Err(e)
            }
        }
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
    ) -> Result<bool, FilesError> {
        let owner = files_repo::app_owner_dir(cx.app_id);
        // Existence check + the CDC surface for the event.
        let Some(prev) = files_repo::head_on(&self.pool, cx.app_id, collection, id).await? else {
            return Ok(false);
        };
        let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);

        let mut tx = self.pool.begin().await.map_err(|e| files_tx_err(&e))?;
        lock_app_quota(&mut tx, cx.app_id, "files")
            .await
            .map_err(|e| files_tx_err(&e))?;
        // Growing an existing blob consumes disk too — an update that skipped the
        // ceiling would be a free bypass. The projection subtracts the blob being
        // replaced, so a same-size-or-smaller update near the cap still goes through.
        //
        // **This MUST precede the blob write.** `write_atomic_at` renames over the
        // FINAL path, destroying the previous bytes. Refusing afterwards would
        // leave the row pointing at a file that is either gone (if we unlink) or
        // has the wrong checksum (if we don't) — i.e. a user would permanently
        // lose a file merely by exceeding a quota.
        self.check_bytes(&mut tx, cx.app_id, Some(id), size).await?;
        let checksum = files_repo::write_atomic_at(&self.root, &owner, collection, id, &upd.data)?;
        let updated = files_repo::update_meta_on(
            &mut *tx,
            cx.app_id,
            collection,
            id,
            MetaPatch {
                name: upd.name.as_deref(),
                content_type: upd.content_type.as_deref(),
                size,
                checksum: &checksum,
            },
        )
        .await?;
        let Some(meta) = updated else {
            // Deleted between the head and the update. The blob we just wrote is
            // now unreferenced.
            files_repo::unlink_blob(&self.root, &owner, collection, id);
            return Ok(false);
        };
        let event =
            files_event("update", collection, &meta, Some(&prev)).map_err(FilesError::Backend)?;
        emit_on(&mut tx, cx, &event)
            .await
            .map_err(|e| FilesError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| files_tx_err(&e))?;
        Ok(true)
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: Uuid) -> Result<bool, FilesError> {
        let mut tx = self.pool.begin().await.map_err(|e| files_tx_err(&e))?;
        let Some(meta) = files_repo::delete_meta_on(&mut *tx, cx.app_id, collection, id).await?
        else {
            return Ok(false);
        };
        let event =
            files_event("delete", collection, &meta, Some(&meta)).map_err(FilesError::Backend)?;
        emit_on(&mut tx, cx, &event)
            .await
            .map_err(|e| FilesError::Backend(format!("event emit: {e}")))?;
        // Commit BEFORE unlinking: a rollback here keeps the row, and a row whose
        // bytes we had already deleted would be unreadable forever.
        tx.commit().await.map_err(|e| files_tx_err(&e))?;
        files_repo::unlink_blob(
            &self.root,
            &files_repo::app_owner_dir(cx.app_id),
            collection,
            id,
        );
        Ok(true)
    }
}

pub struct BestEffortFilesWriter {
    repo: Arc<dyn FilesRepo>,
    events: Arc<dyn ServiceEventEmitter>,
}

impl BestEffortFilesWriter {
    #[must_use]
    pub fn new(repo: Arc<dyn FilesRepo>, events: Arc<dyn ServiceEventEmitter>) -> Self {
        Self { repo, events }
    }
}

#[async_trait]
impl FilesWriter for BestEffortFilesWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        new: NewFile,
    ) -> Result<Uuid, FilesError> {
        let meta = self.repo.create(cx.app_id, collection, new).await?;
        match files_event("create", collection, &meta, None) {
            Ok(event) => best_effort_emit(&*self.events, cx, event).await,
            Err(e) => log_event_build_failure("create", &e),
        }
        Ok(meta.id)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
    ) -> Result<bool, FilesError> {
        let Some(FileUpdated { new, prev }) =
            self.repo.update(cx.app_id, collection, id, upd).await?
        else {
            return Ok(false);
        };
        if let Ok(event) = files_event("update", collection, &new, Some(&prev)) {
            best_effort_emit(&*self.events, cx, event).await;
        }
        Ok(true)
    }

    async fn delete(&self, cx: &SdkCallCx, collection: &str, id: Uuid) -> Result<bool, FilesError> {
        let Some(meta) = self.repo.delete(cx.app_id, collection, id).await? else {
            return Ok(false);
        };
        if let Ok(event) = files_event("delete", collection, &meta, Some(&meta)) {
            best_effort_emit(&*self.events, cx, event).await;
        }
        Ok(true)
    }
}

// ---- §11.6 group (shared-collection) files ----------------------------------

/// The group-files ceiling is bytes-only (no row count): one number, the total
/// stored blob size for the group.
#[derive(Debug, Clone, Copy)]
pub struct GroupFilesQuota {
    pub max_total_bytes: u64,
}

#[async_trait]
pub trait GroupFilesWriter: Send + Sync {
    async fn create(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        new: NewFile,
        quota: GroupFilesQuota,
    ) -> Result<Uuid, GroupFilesError>;

    async fn update(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
        quota: GroupFilesQuota,
    ) -> Result<bool, GroupFilesError>;

    async fn delete(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        id: Uuid,
    ) -> Result<bool, GroupFilesError>;
}

fn group_files_tx_err(e: &sqlx::Error) -> GroupFilesError {
    GroupFilesError::Backend(format!("database error: {e}"))
}

pub struct PostgresGroupFilesWriter {
    pool: PgPool,
    root: PathBuf,
}

impl PostgresGroupFilesWriter {
    #[must_use]
    pub fn new(pool: PgPool, root: PathBuf) -> Self {
        Self { pool, root }
    }

    /// The projected total must fit under the ceiling. Runs on the writing
    /// transaction's connection, under the per-group advisory lock the caller
    /// has already taken — so concurrent uploads to the same group serialize
    /// instead of each seeing the same pre-write total. That matters more here
    /// than for KV/docs: a file is up to 100 MB, so a racing fleet of uploads
    /// could overshoot a 10 GiB ceiling by gigabytes of real disk.
    async fn check_quota(
        conn: &mut PgConnection,
        group_id: GroupId,
        replacing: Option<Uuid>,
        incoming: i64,
        quota: GroupFilesQuota,
    ) -> Result<(), GroupFilesError> {
        let projected =
            group_files_repo::projected_total_bytes_on(&mut *conn, group_id, replacing, incoming)
                .await?;
        if projected > quota.max_total_bytes {
            return Err(GroupFilesError::QuotaExceeded {
                limit: quota.max_total_bytes,
                actual: projected,
            });
        }
        Ok(())
    }
}

#[async_trait]
impl GroupFilesWriter for PostgresGroupFilesWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        new: NewFile,
        quota: GroupFilesQuota,
    ) -> Result<Uuid, GroupFilesError> {
        let owner = group_files_repo::group_owner_dir(group_id);
        let id = Uuid::new_v4();
        let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
        let checksum = files_repo::write_atomic_at(&self.root, &owner, collection, id, &new.data)
            .map_err(GroupFilesRepoError::from)?;

        let committed = async {
            let mut tx = self
                .pool
                .begin()
                .await
                .map_err(|e| group_files_tx_err(&e))?;
            lock_group_quota(&mut tx, group_id, "files")
                .await
                .map_err(|e| group_files_tx_err(&e))?;
            Self::check_quota(&mut tx, group_id, None, size, quota).await?;

            let meta = group_files_repo::insert_meta_on(
                &mut *tx,
                group_id,
                collection,
                id,
                MetaFields {
                    name: &new.name,
                    content_type: &new.content_type,
                    size,
                    checksum: &checksum,
                },
            )
            .await?;
            let event =
                files_event("create", collection, &meta, None).map_err(GroupFilesError::Backend)?;
            emit_shared_on(&mut tx, cx, group_id, &event)
                .await
                .map_err(|e| GroupFilesError::Backend(format!("event emit: {e}")))?;
            tx.commit().await.map_err(|e| group_files_tx_err(&e))?;
            Ok::<_, GroupFilesError>(meta.id)
        }
        .await;

        match committed {
            Ok(id) => Ok(id),
            Err(e) => {
                // Quota refusal or emit failure — nothing references these bytes.
                files_repo::unlink_blob(&self.root, &owner, collection, id);
                Err(e)
            }
        }
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
        quota: GroupFilesQuota,
    ) -> Result<bool, GroupFilesError> {
        let owner = group_files_repo::group_owner_dir(group_id);
        let Some(prev) = group_files_repo::head_on(&self.pool, group_id, collection, id).await?
        else {
            return Ok(false);
        };
        let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
        let checksum = files_repo::write_atomic_at(&self.root, &owner, collection, id, &upd.data)
            .map_err(GroupFilesRepoError::from)?;

        let committed = async {
            let mut tx = self
                .pool
                .begin()
                .await
                .map_err(|e| group_files_tx_err(&e))?;
            lock_group_quota(&mut tx, group_id, "files")
                .await
                .map_err(|e| group_files_tx_err(&e))?;
            // The old service checked NO quota on update at all — so a 1-byte
            // file could be grown to 100 MB without ever consulting the ceiling.
            // The projection subtracts the bytes being replaced, so a same-size
            // or smaller update near the cap still goes through.
            Self::check_quota(&mut tx, group_id, Some(id), size, quota).await?;

            let updated = group_files_repo::update_meta_on(
                &mut *tx,
                group_id,
                collection,
                id,
                MetaPatch {
                    name: upd.name.as_deref(),
                    content_type: upd.content_type.as_deref(),
                    size,
                    checksum: &checksum,
                },
            )
            .await?;
            let Some(meta) = updated else {
                return Ok::<_, GroupFilesError>(None);
            };
            let event = files_event("update", collection, &meta, Some(&prev))
                .map_err(GroupFilesError::Backend)?;
            emit_shared_on(&mut tx, cx, group_id, &event)
                .await
                .map_err(|e| GroupFilesError::Backend(format!("event emit: {e}")))?;
            tx.commit().await.map_err(|e| group_files_tx_err(&e))?;
            Ok(Some(()))
        }
        .await;

        match committed {
            Ok(Some(())) => Ok(true),
            // Refused, or the row vanished under us: either way the bytes we
            // just wrote are not referenced by any committed row.
            Ok(None) => {
                files_repo::unlink_blob(&self.root, &owner, collection, id);
                Ok(false)
            }
            Err(e) => {
                // NOTE: on a rollback the PREVIOUS blob has already been
                // overwritten on disk and cannot be restored. The metadata still
                // points at the old checksum, so a read fails the integrity check
                // rather than returning wrong bytes. This predates the change
                // (the repo overwrote in place too); making the blob write
                // copy-on-write is a separate piece of work.
                Err(e)
            }
        }
    }

    async fn delete(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        id: Uuid,
    ) -> Result<bool, GroupFilesError> {
        // A delete only frees bytes — no quota, so no lock.
        let mut tx = self
            .pool
            .begin()
            .await
            .map_err(|e| group_files_tx_err(&e))?;
        let Some(meta) =
            group_files_repo::delete_meta_on(&mut *tx, group_id, collection, id).await?
        else {
            return Ok(false);
        };
        let event = files_event("delete", collection, &meta, Some(&meta))
            .map_err(GroupFilesError::Backend)?;
        emit_shared_on(&mut tx, cx, group_id, &event)
            .await
            .map_err(|e| GroupFilesError::Backend(format!("event emit: {e}")))?;
        tx.commit().await.map_err(|e| group_files_tx_err(&e))?;
        files_repo::unlink_blob(
            &self.root,
            &group_files_repo::group_owner_dir(group_id),
            collection,
            id,
        );
        Ok(true)
    }
}

pub struct BestEffortGroupFilesWriter {
    repo: Arc<dyn GroupFilesRepo>,
    events: Arc<dyn ServiceEventEmitter>,
}

impl BestEffortGroupFilesWriter {
    #[must_use]
    pub fn new(repo: Arc<dyn GroupFilesRepo>, events: Arc<dyn ServiceEventEmitter>) -> Self {
        Self { repo, events }
    }

    async fn emit(&self, cx: &SdkCallCx, group_id: GroupId, event: ServiceEvent) {
        let (source, op) = (event.source, event.op);
        if let Err(e) = self.events.emit_shared(cx, group_id, event).await {
            tracing::error!(
                error = %e, source, op, event_emit_failure = true,
                "shared event emit failed — the write committed but its triggers will not fire"
            );
        }
    }
}

#[async_trait]
impl GroupFilesWriter for BestEffortGroupFilesWriter {
    async fn create(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        new: NewFile,
        quota: GroupFilesQuota,
    ) -> Result<Uuid, GroupFilesError> {
        let used = self.repo.total_bytes(group_id).await?;
        let incoming = new.data.len() as u64;
        let projected = used.saturating_add(incoming);
        if projected > quota.max_total_bytes {
            return Err(GroupFilesError::QuotaExceeded {
                limit: quota.max_total_bytes,
                actual: projected,
            });
        }
        let meta = self.repo.create(group_id, collection, new).await?;
        match files_event("create", collection, &meta, None) {
            Ok(event) => self.emit(cx, group_id, event).await,
            Err(e) => log_event_build_failure("create", &e),
        }
        Ok(meta.id)
    }

    async fn update(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
        quota: GroupFilesQuota,
    ) -> Result<bool, GroupFilesError> {
        // Mirror the transactional writer's ceiling, so a unit test can catch its
        // removal. (Not serialized — that is what the Postgres writer is for.)
        if let Some(prev) = self.repo.head(group_id, collection, id).await? {
            let used = self.repo.total_bytes(group_id).await?;
            let projected = used
                .saturating_sub(prev.size)
                .saturating_add(upd.data.len() as u64);
            if projected > quota.max_total_bytes {
                return Err(GroupFilesError::QuotaExceeded {
                    limit: quota.max_total_bytes,
                    actual: projected,
                });
            }
        }
        let Some(FileUpdated { new, prev }) =
            self.repo.update(group_id, collection, id, upd).await?
        else {
            return Ok(false);
        };
        if let Ok(event) = files_event("update", collection, &new, Some(&prev)) {
            self.emit(cx, group_id, event).await;
        }
        Ok(true)
    }

    async fn delete(
        &self,
        cx: &SdkCallCx,
        group_id: GroupId,
        collection: &str,
        id: Uuid,
    ) -> Result<bool, GroupFilesError> {
        let Some(meta) = self.repo.delete(group_id, collection, id).await? else {
            return Ok(false);
        };
        if let Ok(event) = files_event("delete", collection, &meta, Some(&meta)) {
            self.emit(cx, group_id, event).await;
        }
        Ok(true)
    }
}
