Skip to main content

coven_database/
blob_declarations.rs

1//! The resolved blob-declaration model: coven's own derivation of which rows
2//! carry blobs and where each blob's columns live.
3//!
4//! The gate-sibling of [`Gates`](crate::Gates). A host declares per
5//! table, via [`SyncedTable::carries_blob`], the columns that locate a blob (its
6//! id, optional readable cloud path, and encryption-scope column) plus the
7//! namespace and retention class. [`BlobDecls::from_tables`] resolves those column
8//! *names* to indices against the live schema for the database handle — the same
9//! `PRAGMA table_info` name→index resolution the gate runs — so coven reads a
10//! row's blob straight off a changeset row or a live `SELECT` with no per-row host
11//! callback.
12//!
13//! From that one model coven derives every blob set it needs:
14//! [`BlobDecls::ref_from_change`] over a changeset row (push upload / pull
15//! download / apply-side local-copy drop),
16//! [`BlobDecls::publication_blobs_in_db`] over the whole database, and
17//! [`BlobDecls::row_for_blob_in_namespace`] to map a blob back to its row by namespace
18//! (the read-path locality dispatch and the make-Remote completion check).
19//!
20//! A declaration's three blob properties — [`Provenance`] (the Local story),
21//! [`CacheFill`] (the Remote story), and [`BlobReplacement`] (whether the row may be
22//! repointed at a different blob) — are described by the blob concept tree in
23//! the replication layer. Write-once updates are refused here, where the declaration
24//! and changed blob-id column are available. Immutable cloud-object identity comes
25//! from the blob locator and retained exact object reference.
26
27use std::collections::HashMap;
28
29use rusqlite::{Connection, OptionalExtension};
30
31use crate::{quote_ident, table_columns as session_table_columns};
32use coven_foundation::changeset::{ChangeOp, RowChange};
33use coven_protocol::blob::{BlobRef, BlobReplacement, BlobScope, CacheFill, Provenance};
34use coven_protocol::synced_schema::SyncedTable;
35
36/// Why building the blob-declaration model failed.
37#[derive(Debug)]
38pub enum BlobDeclError {
39    /// A declared blob column is absent from the table's live schema.
40    MissingColumn { table: String, column: String },
41    /// A schema read (`PRAGMA table_info`) failed.
42    Sqlite(rusqlite::Error),
43    /// A captured host changeset could not be read.
44    Changeset(crate::ChangesetError),
45    /// A row's declared size column is negative.
46    InvalidSize { table: String, value: i64 },
47    /// A row names a blob but has no content hash.
48    MissingHash { table: String, row_id: String },
49    /// New and old changeset walks produced different row counts.
50    ChangesetWalkMismatch { old_count: usize, new_count: usize },
51    /// A blob-bearing INSERT or UPDATE has no primary key.
52    MissingPublicationPrimaryKey { table: String },
53    /// The transaction row named by a blob-bearing change is absent.
54    MissingPublicationRow { table: String, primary_key: String },
55    /// The transaction row named by a blob-bearing change no longer carries a blob.
56    MissingPublicationBlob { table: String, primary_key: String },
57    /// The transaction row carries a different blob than its change introduced.
58    PublicationBlobMismatch {
59        table: String,
60        primary_key: String,
61        changed_blob_id: String,
62        row_blob_id: String,
63    },
64    /// A [`WriteOnce`](BlobReplacement::WriteOnce) changeset update changed the
65    /// row's blob-id column, contrary to its declaration.
66    WriteOnceBlobRepointed { table: String, blob_id: String },
67}
68
69impl std::fmt::Display for BlobDeclError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            BlobDeclError::MissingColumn { table, column } => {
73                write!(
74                    f,
75                    "blob declaration names column {column:?} absent from {table:?}"
76                )
77            }
78            BlobDeclError::Sqlite(e) => write!(f, "blob declaration schema read failed: {e}"),
79            BlobDeclError::Changeset(error) => {
80                write!(f, "blob declaration changeset read failed: {error}")
81            }
82            BlobDeclError::InvalidSize { table, value } => {
83                write!(f, "blob declaration found invalid size in {table}: {value}")
84            }
85            BlobDeclError::MissingHash { table, row_id } => write!(
86                f,
87                "blob-bearing row {table:?}/{row_id:?} has no content hash"
88            ),
89            BlobDeclError::ChangesetWalkMismatch {
90                old_count,
91                new_count,
92            } => write!(
93                f,
94                "blob declaration changeset walk mismatch: old={old_count}, new={new_count}"
95            ),
96            BlobDeclError::MissingPublicationPrimaryKey { table } => {
97                write!(
98                    f,
99                    "blob-bearing Store write row in {table:?} has no primary key"
100                )
101            }
102            BlobDeclError::MissingPublicationRow { table, primary_key } => write!(
103                f,
104                "blob-bearing Store write row {table:?}/{primary_key:?} is absent before commit"
105            ),
106            BlobDeclError::MissingPublicationBlob { table, primary_key } => write!(
107                f,
108                "blob-bearing Store write row {table:?}/{primary_key:?} no longer carries a blob"
109            ),
110            BlobDeclError::PublicationBlobMismatch {
111                table,
112                primary_key,
113                changed_blob_id,
114                row_blob_id,
115            } => write!(
116                f,
117                "blob-bearing Store write row {table:?}/{primary_key:?} changed from introduced blob \
118                 {changed_blob_id:?} to {row_blob_id:?} before commit"
119            ),
120            BlobDeclError::WriteOnceBlobRepointed { table, blob_id } => write!(
121                f,
122                "write-once row in {table} was repointed at blob {blob_id}: its declaration \
123                 forbids changing the blob-id column. Declare the table replaceable if its \
124                 rows are meant to be repointed"
125            ),
126        }
127    }
128}
129
130impl std::error::Error for BlobDeclError {}
131
132impl From<rusqlite::Error> for BlobDeclError {
133    fn from(e: rusqlite::Error) -> Self {
134        BlobDeclError::Sqlite(e)
135    }
136}
137
138/// Exact row facts captured with a durable Store write for one blob-bearing row.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct PublicationBlob {
141    pub table: String,
142    pub row_id: String,
143    pub row_stamp: String,
144    pub column: String,
145    pub blob: BlobRef,
146    pub plaintext_size: u64,
147    pub plaintext_hash: String,
148}
149
150/// A blob-bearing table's columns resolved to indices in the live schema (the
151/// same order a changeset reports its columns, so an index reads either source).
152struct TableBlob {
153    namespace: String,
154    provenance: Provenance,
155    fill: CacheFill,
156    columns: BlobColumns,
157    /// Name of the blob-id column. The index reads a row top-to-bottom; the name
158    /// keys a lookup the other way ([`BlobDecls::row_for_blob_in_namespace`]: which row
159    /// carries a given blob id), so both directions resolve off the same declaration.
160    id_col_name: String,
161    /// The encryption scope, fixed per table by the declaration.
162    scope: BlobScope,
163    /// This table's row-repointing policy, enforced by [`TableBlob::ref_from_change`].
164    replacement: BlobReplacement,
165}
166
167/// The declared content fields of one blob, resolved against its table schema.
168/// These fields form one merge value even when only some change in an UPDATE.
169pub(crate) struct BlobColumns {
170    id: usize,
171    size: usize,
172    hash: usize,
173    cloud_path: Option<usize>,
174}
175
176impl BlobColumns {
177    pub(crate) fn resolve(
178        table: &str,
179        declaration: &coven_protocol::synced_schema::BlobDecl,
180        columns: &[String],
181    ) -> Result<Self, BlobDeclError> {
182        let index = |name: &str| {
183            columns
184                .iter()
185                .position(|column| column == name)
186                .ok_or_else(|| BlobDeclError::MissingColumn {
187                    table: table.to_string(),
188                    column: name.to_string(),
189                })
190        };
191        Ok(Self {
192            id: index(&declaration.id_column)?,
193            size: index(&declaration.size_column)?,
194            hash: index(&declaration.hash_column)?,
195            cloud_path: declaration
196                .cloud_path_column
197                .as_deref()
198                .map(index)
199                .transpose()?,
200        })
201    }
202
203    pub(crate) fn iter(&self) -> impl Iterator<Item = usize> + '_ {
204        [
205            Some(self.id),
206            Some(self.size),
207            Some(self.hash),
208            self.cloud_path,
209        ]
210        .into_iter()
211        .flatten()
212    }
213}
214
215impl TableBlob {
216    /// Build the row's blob from its declared identity and readable path.
217    /// Shared by changeset and live-row readers; locator construction owns
218    /// path validation and exact cloud-object identity.
219    fn blob_ref(&self, id: String, cloud_path: Option<String>) -> BlobRef {
220        BlobRef {
221            namespace: self.namespace.clone(),
222            id,
223            scope: self.scope.clone(),
224            cloud_path,
225            provenance: self.provenance,
226            fill: self.fill,
227        }
228    }
229
230    /// The blob a changeset row references.
231    ///
232    /// The gate a [`WriteOnce`](BlobReplacement::WriteOnce) row passes through. A
233    /// changeset UPDATE marks only the columns whose values changed. The decoded row
234    /// also carries unchanged primary-key values, so write-once enforcement must
235    /// inspect that marker before treating its blob id as a repointing. A real repoint
236    /// is refused here, where the change is read and the declaration is available.
237    fn ref_from_change(
238        &self,
239        table: &str,
240        change: &RowChange,
241    ) -> Result<Option<BlobRef>, BlobDeclError> {
242        let Some(id) = change.col(self.columns.id).map(str::to_string) else {
243            return Ok(None);
244        };
245        if self.replacement == BlobReplacement::WriteOnce
246            && change.op == ChangeOp::Update
247            && change.column_changed(self.columns.id)
248        {
249            return Err(BlobDeclError::WriteOnceBlobRepointed {
250                table: table.to_string(),
251                blob_id: id,
252            });
253        }
254        let cloud_path = self
255            .columns
256            .cloud_path
257            .and_then(|i| change.col(i))
258            .map(str::to_string);
259        Ok(Some(self.blob_ref(id, cloud_path)))
260    }
261
262    /// Build the [`BlobRef`] for a live `SELECT *` row of this table, or `None` when
263    /// the row's blob id is NULL. The resolved
264    /// indices address a `SELECT *` row in schema order, exactly as they address a
265    /// changeset row.
266    fn ref_from_row(&self, row: &rusqlite::Row<'_>) -> Result<Option<BlobRef>, BlobDeclError> {
267        let Some(id) = row.get::<_, Option<String>>(self.columns.id)? else {
268            return Ok(None);
269        };
270        let cloud_path = match self.columns.cloud_path {
271            Some(i) => row.get::<_, Option<String>>(i)?,
272            None => None,
273        };
274        Ok(Some(self.blob_ref(id, cloud_path)))
275    }
276
277    fn size_from_row(&self, table: &str, row: &rusqlite::Row<'_>) -> Result<u64, BlobDeclError> {
278        let value = row.get::<_, i64>(self.columns.size)?;
279        u64::try_from(value).map_err(|_| BlobDeclError::InvalidSize {
280            table: table.to_string(),
281            value,
282        })
283    }
284
285    fn hash_from_row(
286        &self,
287        table: &str,
288        row_id: &str,
289        row: &rusqlite::Row<'_>,
290    ) -> Result<String, BlobDeclError> {
291        row.get::<_, Option<String>>(self.columns.hash)?
292            .ok_or_else(|| BlobDeclError::MissingHash {
293                table: table.to_string(),
294                row_id: row_id.to_string(),
295            })
296    }
297}
298
299/// The blob declarations for a database handle, resolved from the declared set +
300/// the live schema at open. A synced table absent from this map carries no blob.
301pub struct BlobDecls {
302    tables: HashMap<String, TableBlob>,
303}
304
305#[cfg(any(test, feature = "test-utils"))]
306thread_local! {
307    static FROM_TABLES_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
308}
309
310#[cfg(any(test, feature = "test-utils"))]
311pub fn reset_from_tables_call_count() {
312    FROM_TABLES_CALLS.with(|calls| calls.set(0));
313}
314
315#[cfg(any(test, feature = "test-utils"))]
316pub fn from_tables_call_count() -> usize {
317    FROM_TABLES_CALLS.with(std::cell::Cell::get)
318}
319
320impl BlobDecls {
321    /// Resolve every [`SyncedTable::carries_blob`] declaration's column names to
322    /// indices against the live schema, mirroring
323    /// [`Gates::from_tables`](crate::Gates::from_tables). A declared
324    /// column absent from the table is a host error surfaced here, never a silent
325    /// drop.
326    pub(crate) fn from_tables(
327        conn: &Connection,
328        tables: &[SyncedTable],
329    ) -> Result<Self, BlobDeclError> {
330        #[cfg(any(test, feature = "test-utils"))]
331        FROM_TABLES_CALLS.with(|calls| calls.set(calls.get() + 1));
332
333        let mut map = HashMap::new();
334        for t in tables {
335            let Some(decl) = t.blob() else {
336                continue;
337            };
338            // Column names in declared (schema) order — the index of a name here
339            // is the index a changeset reports for that column.
340            let cols = session_table_columns(conn, t.name()).map_err(BlobDeclError::from)?;
341            let columns = BlobColumns::resolve(t.name(), decl, &cols)?;
342
343            map.insert(
344                t.name().to_string(),
345                TableBlob {
346                    namespace: decl.namespace.clone(),
347                    provenance: decl.provenance,
348                    fill: decl.fill,
349                    columns,
350                    id_col_name: decl.id_column.clone(),
351                    scope: decl.scope.clone(),
352                    replacement: decl.replacement,
353                },
354            );
355        }
356        Ok(BlobDecls { tables: map })
357    }
358
359    /// Retain complete old/new content values for an actual blob edit. Metadata
360    /// updates stay sparse, so they cannot overwrite independently edited content.
361    pub(crate) fn complete_blob_changeset(
362        &self,
363        conn: &Connection,
364        changeset: &[u8],
365    ) -> Result<Vec<u8>, crate::DbError> {
366        if self.tables.is_empty() || changeset.is_empty() {
367            return Ok(changeset.to_vec());
368        }
369        let group = crate::gate::Changegroup::new().map_err(crate::DbError::from)?;
370        unsafe {
371            group
372                .set_schema(conn.handle())
373                .map_err(crate::DbError::from)?;
374            crate::gate::for_each_change(changeset, |iter, change| {
375                let Some(blob) = self
376                    .tables
377                    .get(&change.table)
378                    .filter(|_| change.op == rusqlite::ffi::SQLITE_UPDATE)
379                else {
380                    return group.add_change(iter);
381                };
382                let (mut old, mut new, indirect) = crate::gate::update_values(iter)?;
383                let edited = blob.columns.iter().filter(|index| *index != 0).any(|index| {
384                    matches!((&old[index], &new[index]), (Some(old), Some(new)) if old != new)
385                });
386                if !edited {
387                    return group.add_change(iter);
388                }
389                let pk = change.pk().ok_or_else(|| {
390                    crate::GateError::MissingChangesetPrimaryKey(change.table.clone())
391                })?;
392                let sql = format!("SELECT * FROM {} WHERE id = ?1", quote_ident(&change.table));
393                let values = conn
394                    .query_row(&sql, [pk], |row| {
395                        blob.columns
396                            .iter()
397                            .filter(|index| *index != 0)
398                            .map(|index| {
399                                row.get::<_, rusqlite::types::Value>(index)
400                                    .map(|value| (index, value))
401                            })
402                            .collect::<rusqlite::Result<Vec<_>>>()
403                    })
404                    .map_err(|error| {
405                        crate::GateError::Sql(
406                            format!("capture blob content in {}", change.table),
407                            error,
408                        )
409                    })?;
410                for (index, value) in values {
411                    if old[index].is_none() && new[index].is_none() {
412                        old[index] = Some(value.clone());
413                        new[index] = Some(value);
414                    }
415                }
416                group.add_update(&change.table, &old, &new, indirect)
417            })
418            .map_err(crate::DbError::from)?;
419        }
420        group.output().map_err(crate::DbError::from)
421    }
422
423    /// Install connection-local guards that keep a blob cleanup intent exclusive
424    /// until its filesystem deletion finishes. A cleanup intent is committed
425    /// before the database releases the row; while either cleanup queue holds it,
426    /// no INSERT or UPDATE may make the same `(namespace, blob id)` live again.
427    /// TEMP triggers keep this runtime guard out of snapshots and use each
428    /// declaration's resolved blob-id column rather than assuming the row primary
429    /// key carries the blob id.
430    pub(crate) fn install_cleanup_guards(&self, conn: &Connection) -> Result<(), BlobDeclError> {
431        for (table, blob) in &self.tables {
432            let table_ident = quote_ident(table);
433            let id_ident = quote_ident(&blob.id_col_name);
434            let namespace_literal: String =
435                conn.query_row("SELECT quote(?1)", [&blob.namespace], |row| row.get(0))?;
436            for (trigger_kind, event_clause) in [
437                ("insert", "BEFORE INSERT".to_string()),
438                ("update", format!("BEFORE UPDATE OF {id_ident}")),
439            ] {
440                let trigger = quote_ident(&format!(
441                    "{}{trigger_kind}_{table}",
442                    super::COVEN_CLEANUP_GUARD_PREFIX
443                ));
444                conn.execute_batch(&format!(
445                    "CREATE TEMP TRIGGER {trigger} \
446                     {event_clause} ON main.{table_ident} \
447                     WHEN NEW.{id_ident} IS NOT NULL AND (\
448                         EXISTS (\
449                             SELECT 1 FROM local_cleanup_intents \
450                             WHERE namespace = {namespace_literal} \
451                               AND blob_id = NEW.{id_ident}\
452                         ) OR EXISTS (\
453                             SELECT 1 FROM published_blob_drop_intents \
454                             WHERE namespace = {namespace_literal} \
455                               AND blob_id = NEW.{id_ident}\
456                         )\
457                     ) \
458                     BEGIN \
459                         SELECT RAISE(ABORT, 'blob local cleanup in progress'); \
460                     END;"
461                ))?;
462            }
463        }
464        Ok(())
465    }
466
467    /// The blob a single changeset row references, or `None` when the row's table
468    /// carries no blob or the blob id is absent/NULL. Reads the declared columns
469    /// off the changeset row (which reports columns in schema order, the order the
470    /// resolved indices address).
471    pub fn ref_from_change(&self, change: &RowChange) -> Result<Option<BlobRef>, BlobDeclError> {
472        let Some(tb) = self.tables.get(&change.table) else {
473            return Ok(None);
474        };
475        tb.ref_from_change(&change.table, change)
476    }
477
478    /// The exact blob reference and declared size owned by an INSERT or UPDATE,
479    /// completed from that row inside the transaction that produced the change.
480    /// An UPDATE can omit its unchanged blob-id column. The live transaction row
481    /// supplies that identity and its content facts before a later write can
482    /// repoint or delete it; an identity present in the change must still match.
483    pub(crate) fn publication_blob_from_change(
484        &self,
485        conn: &Connection,
486        change: &RowChange,
487    ) -> Result<Option<PublicationBlob>, BlobDeclError> {
488        if !matches!(change.op, ChangeOp::Insert | ChangeOp::Update) {
489            return Ok(None);
490        }
491        let Some(tb) = self.tables.get(&change.table) else {
492            return Ok(None);
493        };
494        let changed_blob = tb.ref_from_change(&change.table, change)?;
495        if changed_blob.is_none()
496            && (change.op == ChangeOp::Insert || change.column_changed(tb.columns.id))
497        {
498            return Ok(None);
499        }
500        let pk = change
501            .pk()
502            .ok_or_else(|| BlobDeclError::MissingPublicationPrimaryKey {
503                table: change.table.clone(),
504            })?;
505        let publication = match self.publication_blob_for_row(conn, &change.table, pk) {
506            Ok(Some(publication)) => publication,
507            Ok(None) => {
508                return Err(BlobDeclError::MissingPublicationRow {
509                    table: change.table.clone(),
510                    primary_key: pk.to_string(),
511                });
512            }
513            Err(BlobDeclError::MissingPublicationBlob { .. }) if changed_blob.is_none() => {
514                return Ok(None);
515            }
516            Err(error) => return Err(error),
517        };
518        if let Some(changed_blob) = changed_blob {
519            if publication.blob.id != changed_blob.id {
520                return Err(BlobDeclError::PublicationBlobMismatch {
521                    table: change.table.clone(),
522                    primary_key: pk.to_string(),
523                    changed_blob_id: changed_blob.id,
524                    row_blob_id: publication.blob.id.clone(),
525                });
526            }
527        }
528        Ok(Some(publication))
529    }
530
531    pub(crate) fn publication_blob_for_row(
532        &self,
533        conn: &Connection,
534        table: &str,
535        row_id: &str,
536    ) -> Result<Option<PublicationBlob>, BlobDeclError> {
537        let Some(blob) = self.tables.get(table) else {
538            return Ok(None);
539        };
540        let sql = format!("SELECT * FROM {} WHERE id = ?1", quote_ident(table));
541        let mut statement = conn.prepare(&sql)?;
542        let mut rows = statement.query([row_id])?;
543        rows.next()?
544            .map(|row| publication_blob_from_row(table, blob, row))
545            .transpose()
546    }
547
548    /// Require every inserted or updated blob-bearing row in `changeset` to
549    /// carry complete final content facts before its transaction can commit.
550    pub(crate) fn validate_changed_rows(
551        &self,
552        conn: &Connection,
553        changeset: &[u8],
554    ) -> Result<(), BlobDeclError> {
555        let changes = crate::walk_changeset(changeset).map_err(BlobDeclError::Changeset)?;
556        for change in changes {
557            if !matches!(change.op, ChangeOp::Insert | ChangeOp::Update)
558                || !self.tables.contains_key(&change.table)
559            {
560                continue;
561            }
562            let row_id =
563                change
564                    .pk()
565                    .ok_or_else(|| BlobDeclError::MissingPublicationPrimaryKey {
566                        table: change.table.clone(),
567                    })?;
568            match self.publication_blob_for_row(conn, &change.table, row_id) {
569                Ok(_) | Err(BlobDeclError::MissingPublicationBlob { .. }) => {}
570                Err(error) => return Err(error),
571            }
572        }
573        Ok(())
574    }
575
576    /// Every exact blob-bearing row version currently present in `conn`.
577    pub(crate) fn publication_blobs_in_db(
578        &self,
579        conn: &Connection,
580    ) -> Result<Vec<PublicationBlob>, BlobDeclError> {
581        let mut out = Vec::new();
582        for (table, blob) in &self.tables {
583            let sql = format!("SELECT * FROM {}", quote_ident(table));
584            let mut statement = conn.prepare(&sql)?;
585            let mut rows = statement.query([])?;
586            while let Some(row) = rows.next()? {
587                let Some(reference) = blob.ref_from_row(row)? else {
588                    continue;
589                };
590                let row_id = row.get::<_, String>("id")?;
591                out.push(PublicationBlob {
592                    table: table.clone(),
593                    row_id: row_id.clone(),
594                    row_stamp: row.get("_updated_at")?,
595                    column: blob.id_col_name.clone(),
596                    blob: reference,
597                    plaintext_size: blob.size_from_row(table, row)?,
598                    plaintext_hash: blob.hash_from_row(table, &row_id, row)?,
599                });
600            }
601        }
602        out.sort_by(|left, right| {
603            (&left.table, &left.row_id, &left.column, &left.row_stamp).cmp(&(
604                &right.table,
605                &right.row_id,
606                &right.column,
607                &right.row_stamp,
608            ))
609        });
610        Ok(out)
611    }
612
613    /// The `(table, primary key)` of the row carrying `blob_id` in the table declared
614    /// for `namespace` — the carrying table resolved from the blob's own namespace
615    /// (part of its address), not by scanning every blob-bearing table. `None` when no
616    /// declared table owns `namespace`, or that table has no row with the id. Both the
617    /// read path (locality dispatch) and the make-Remote completion check use this, so
618    /// a blob id that collides across namespaces always reads the right table's gate,
619    /// never the first id match.
620    pub(crate) fn row_for_blob_in_namespace(
621        &self,
622        conn: &Connection,
623        namespace: &str,
624        blob_id: &str,
625    ) -> Result<Option<(String, String)>, BlobDeclError> {
626        let Some((table, tb)) = self.table_for_namespace(namespace) else {
627            return Ok(None);
628        };
629        let sql = format!(
630            "SELECT id FROM {} WHERE {} = ?1",
631            quote_ident(table),
632            quote_ident(&tb.id_col_name),
633        );
634        conn.query_row(&sql, [blob_id], |row| row.get::<_, String>(0))
635            .optional()
636            .map(|primary_key| primary_key.map(|primary_key| (table.clone(), primary_key)))
637            .map_err(BlobDeclError::from)
638    }
639
640    /// The one `(table, declaration)` whose blob namespace is `namespace`, or
641    /// `None` when no declared table owns it. The namespace is part of a blob's
642    /// address, so this resolves the carrying table without scanning every
643    /// blob-bearing table's rows.
644    fn table_for_namespace(&self, namespace: &str) -> Option<(&String, &TableBlob)> {
645        self.tables.iter().find(|(_, tb)| tb.namespace == namespace)
646    }
647
648    /// Whether a live row still needs the logical-id-keyed local source for this
649    /// blob. A row needs that source exactly when its current stamp has no installed
650    /// remote locator binding.
651    pub(crate) fn local_copy_is_referenced(
652        &self,
653        conn: &Connection,
654        namespace: &str,
655        blob_id: &str,
656    ) -> Result<bool, BlobDeclError> {
657        let Some((table, blob)) = self.table_for_namespace(namespace) else {
658            return Ok(false);
659        };
660        let sql = format!(
661            "SELECT EXISTS(
662                 SELECT 1 FROM {table} AS live
663                 WHERE CAST(live.{blob_column} AS TEXT) = ?1
664                   AND NOT EXISTS (
665                       SELECT 1 FROM row_blob_locators AS binding
666                       WHERE binding.table_name = ?2
667                         AND binding.row_id = CAST(live.id AS TEXT)
668                         AND binding.column_name = ?3
669                         AND binding.row_stamp = CAST(live._updated_at AS TEXT)
670                   )
671             )",
672            table = quote_ident(table),
673            blob_column = quote_ident(&blob.id_col_name),
674        );
675        conn.query_row(
676            &sql,
677            rusqlite::params![blob_id, table, blob.id_col_name],
678            |row| row.get(0),
679        )
680        .map_err(BlobDeclError::from)
681    }
682
683    /// Whether any live row still carries this logical blob ID, independent of
684    /// whether that row currently resolves to a local source or an exact remote
685    /// locator.
686    pub(crate) fn blob_id_is_referenced(
687        &self,
688        conn: &Connection,
689        namespace: &str,
690        blob_id: &str,
691    ) -> Result<bool, BlobDeclError> {
692        let Some((table, blob)) = self.table_for_namespace(namespace) else {
693            return Ok(false);
694        };
695        let sql = format!(
696            "SELECT EXISTS(
697                 SELECT 1 FROM {table}
698                 WHERE CAST({blob_column} AS TEXT) = ?1
699             )",
700            table = quote_ident(table),
701            blob_column = quote_ident(&blob.id_col_name),
702        );
703        conn.query_row(&sql, [blob_id], |row| row.get(0))
704            .map_err(BlobDeclError::from)
705    }
706
707    /// Whether a live row's current stamp still names one exact locator. A row
708    /// that merely reuses the same logical blob id under another locator does not
709    /// retain this locator's cache or pinned file.
710    pub(crate) fn exact_copy_is_referenced(
711        &self,
712        conn: &Connection,
713        namespace: &str,
714        blob_id: &str,
715        locator_hash: coven_protocol::store_commit::ObjectHash,
716    ) -> Result<bool, BlobDeclError> {
717        let Some((table, blob)) = self.table_for_namespace(namespace) else {
718            return Ok(false);
719        };
720        let sql = format!(
721            "SELECT EXISTS(
722                 SELECT 1
723                 FROM {table} AS live
724                 JOIN row_blob_locators AS binding
725                   ON binding.table_name = ?2
726                  AND binding.row_id = CAST(live.id AS TEXT)
727                  AND binding.column_name = ?3
728                  AND binding.row_stamp = CAST(live._updated_at AS TEXT)
729                 JOIN blob_locators AS locator
730                   ON locator.remote_object_id = binding.remote_object_id
731                 WHERE CAST(live.{blob_column} AS TEXT) = ?1
732                   AND locator.locator_hash = ?4
733             )",
734            table = quote_ident(table),
735            blob_column = quote_ident(&blob.id_col_name),
736        );
737        conn.query_row(
738            &sql,
739            rusqlite::params![blob_id, table, blob.id_col_name, locator_hash.to_string()],
740            |row| row.get(0),
741        )
742        .map_err(BlobDeclError::from)
743    }
744}
745
746fn publication_blob_from_row(
747    table: &str,
748    blob: &TableBlob,
749    row: &rusqlite::Row<'_>,
750) -> Result<PublicationBlob, BlobDeclError> {
751    let row_id = row.get::<_, String>("id")?;
752    let reference =
753        blob.ref_from_row(row)?
754            .ok_or_else(|| BlobDeclError::MissingPublicationBlob {
755                table: table.to_string(),
756                primary_key: row_id.clone(),
757            })?;
758    let plaintext_hash = blob.hash_from_row(table, &row_id, row)?;
759    Ok(PublicationBlob {
760        table: table.to_string(),
761        row_id,
762        row_stamp: row.get("_updated_at")?,
763        column: blob.id_col_name.clone(),
764        blob: reference,
765        plaintext_size: blob.size_from_row(table, row)?,
766        plaintext_hash,
767    })
768}
769
770#[cfg(test)]
771#[path = "blob_declarations_tests.rs"]
772mod tests;