1use 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#[derive(Debug)]
38pub enum BlobDeclError {
39 MissingColumn { table: String, column: String },
41 Sqlite(rusqlite::Error),
43 Changeset(crate::ChangesetError),
45 InvalidSize { table: String, value: i64 },
47 MissingHash { table: String, row_id: String },
49 ChangesetWalkMismatch { old_count: usize, new_count: usize },
51 MissingPublicationPrimaryKey { table: String },
53 MissingPublicationRow { table: String, primary_key: String },
55 MissingPublicationBlob { table: String, primary_key: String },
57 PublicationBlobMismatch {
59 table: String,
60 primary_key: String,
61 changed_blob_id: String,
62 row_blob_id: String,
63 },
64 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#[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
150struct TableBlob {
153 namespace: String,
154 provenance: Provenance,
155 fill: CacheFill,
156 columns: BlobColumns,
157 id_col_name: String,
161 scope: BlobScope,
163 replacement: BlobReplacement,
165}
166
167pub(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 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 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 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
299pub 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 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 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 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 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 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 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 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 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 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 fn table_for_namespace(&self, namespace: &str) -> Option<(&String, &TableBlob)> {
645 self.tables.iter().find(|(_, tb)| tb.namespace == namespace)
646 }
647
648 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 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 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;